diff --git a/CLAUDE.md b/CLAUDE.md index 3591ac5..340e738 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ OCCTSwiftIO built clean with zero errors while carrying three real breaks in its Dependencies resolve against local siblings when present (`../OCCTSwift` and friends), else the published URLs. No binary lives in this repo. -**Expected baseline: 330 tests across 28 suites, all passing.** +**Expected baseline: 343 tests across 30 suites, all passing.** ## Face identity is `IsSame`, and that decision is settled @@ -113,9 +113,45 @@ Three behaviours look like the resolver's job and are not, so they stayed where Pulling any of them down would drag presentation and viewport state into the bridge layer, which is the thing this consolidation exists to prevent. -Still outstanding from the same epic: the two parallel selection systems (`select`, -`clearSelection`, `remove`, `removeAll` in both `OCCTSwiftAIS` and `OCCTSwiftCADKit`) are issue #3, -and each remaining collision gets its own behaviour matrix before either copy is deleted. +## One selection, held by `InteractiveContext` + +Phase 3 of ecosystem#43, done in +[OCCTSwiftInteraction#3](https://github.com/SecondMouseAU/OCCTSwiftInteraction/issues/3). + +`OCCTSwiftAIS.InteractiveContext.selection` is the only selection store in this package. +`OCCTSwiftCADKit.CADViewportService` used to keep a second one alongside the context it already +owned; it now drives that one and projects it: + +- `CADViewportService.selection` is `interactiveContext.selection` enriched into `PickedEntity` + values. Mirrored into stored state so SwiftUI observation fires, exactly as `bodies` mirrors + `interactiveContext.bodies`. Ordered by (body id, kind, ordinal), since the store is a `Set`. +- `CADViewportService.selectionModes` **is** `interactiveContext.selectionMode`, not a copy. + Initialised to `[.face]` at `init`, overriding the context's `[.body]` default. +- `select`, `clearSelection` and the selection pruning inside `remove`/`removeAll` all go + through the context. + +`InteractiveContext.select(_:scheme:)` gained the four-scheme parameter from CADKit's version. +`select(_:)` is untouched and still means `.add`; the scheme parameter is deliberately not +defaulted, because a default would silently retune every existing call site to `.replace`. + +What did **not** merge, and why: + +- **`PickedFaceInfo`/`PickedEdgeInfo`/`PickedVertexInfo` survive** as presentation types, now + storing a `SubShapeRef` and forwarding `shape`/`uid`/`ordinal` to it. Two of their fields + (`scalarValue` for a per-triangle field, `description`) cannot be recovered from a ref after + the fact. +- **The highlight systems stay separate.** AIS paints `triangleStyles`; CADKit builds aggregate + highlight bodies. CADKit already uses `triangleStyles` for scalar fields, so they would + overwrite each other. That is also why CADKit's bodies are not registered as context entries. +- **The clip-plane pre-filter stays in CADKit.** It tests the picked primitive's position, which + an AIS `SelectionFilter` (which sees a resolved `SubShape`) cannot express. +- **`Axis` in both targets was never a collision**: AIS's is nested inside `ManipulatorWidget`. +- **`SelectionSummary` was never a duplicate of OCCTSwiftUX's.** Different fields, different + inputs, no shared consumer, and OCCTSwiftUX does not depend on this package at all. Resolved + by naming: CADKit's is now `SelectionMeasurements`, with a deprecated alias. + +`ComesFromDecomposition` is settled and is **not** going on `SubShapeRef`: `SubShape` is a sum +type, so `ref == nil` already is "whole body", and the resolver can never mint a whole-body ref. ## Where things are @@ -127,7 +163,7 @@ Each module kept its own documentation through the merge rather than having it c | Getting started | none | [guide](docs/guides/getting-started-OCCTSwiftAIS.md) | [guide](docs/guides/getting-started-OCCTSwiftCADKit.md) | | Module notes | this file | [docs/module-notes/OCCTSwiftAIS.md](docs/module-notes/OCCTSwiftAIS.md) | [docs/module-notes/OCCTSwiftCADKit.md](docs/module-notes/OCCTSwiftCADKit.md) | | okf component | [okf/components/OCCTSwiftTools.md](okf/components/OCCTSwiftTools.md) | [okf/components/OCCTSwiftAIS.md](okf/components/OCCTSwiftAIS.md) | [okf/components/OCCTSwiftCADKit.md](okf/components/OCCTSwiftCADKit.md) | -| Changelog | [docs/CHANGELOG-OCCTSwiftTools.md](docs/CHANGELOG-OCCTSwiftTools.md) | [docs/CHANGELOG-OCCTSwiftAIS.md](docs/CHANGELOG-OCCTSwiftAIS.md) | none | +| Changelog | [docs/CHANGELOG-OCCTSwiftTools.md](docs/CHANGELOG-OCCTSwiftTools.md) | [docs/CHANGELOG-OCCTSwiftAIS.md](docs/CHANGELOG-OCCTSwiftAIS.md) | [docs/CHANGELOG-OCCTSwiftCADKit.md](docs/CHANGELOG-OCCTSwiftCADKit.md) | `docs/module-notes/*.md` are the pre-merge `CLAUDE.md` files kept verbatim. They still speak as though their module is its own repository; read them for module-specific traps, not for repo layout. diff --git a/Sources/OCCTSwiftAIS/AreaSelection.swift b/Sources/OCCTSwiftAIS/AreaSelection.swift index 13b0845..5f8ac89 100644 --- a/Sources/OCCTSwiftAIS/AreaSelection.swift +++ b/Sources/OCCTSwiftAIS/AreaSelection.swift @@ -160,12 +160,7 @@ extension InteractiveContext { } } - switch scheme { - case .replace: setSelection(Selection(matched)) - case .add: setSelection(Selection(selection.subshapes.union(matched))) - case .remove: setSelection(Selection(selection.subshapes.subtracting(matched))) - case .xor: setSelection(Selection(selection.subshapes.symmetricDifference(matched))) - } + applySelection(matched, scheme: scheme) } private func project( diff --git a/Sources/OCCTSwiftAIS/InteractiveContext.swift b/Sources/OCCTSwiftAIS/InteractiveContext.swift index a864d60..df8d8a8 100644 --- a/Sources/OCCTSwiftAIS/InteractiveContext.swift +++ b/Sources/OCCTSwiftAIS/InteractiveContext.swift @@ -252,29 +252,57 @@ public final class InteractiveContext: ObservableObject { /// Add a sub-shape to the current selection. /// - /// Idempotent. + /// Idempotent. Exactly `select(subshape, scheme: .add)`; kept as its own + /// method (rather than giving `select(_:scheme:)` a default) so that every + /// existing `select(x)` call site keeps meaning "add", which is what it has + /// always meant. A defaulted `scheme:` would have silently retuned all of + /// them to `.replace`. public func select(_ subshape: SubShape) { - var s = selection.subshapes - s.insert(subshape) - selection = Selection(s) + select(subshape, scheme: .add) + } + + /// Combine one sub-shape with the current selection per `scheme`. + /// + /// The scheme parameter came from `OCCTSwiftCADKit.CADViewportService.select(_:scheme:)`, + /// which had all four schemes where this context had only `.add` (`select`) and + /// `.remove` (`deselect`). That service now drives this selection rather than keeping + /// a parallel one (OCCTSwiftInteraction#3, phase 3 of ecosystem#43), so the schemes + /// live here with the state. + /// + /// Semantics match `SelectionScheme` everywhere else in this target, including area + /// selection: `.replace` assigns, `.add` inserts if absent, `.remove` drops it, `.xor` + /// toggles it. + public func select(_ subshape: SubShape, scheme: SelectionScheme) { + applySelection([subshape], scheme: scheme) } public func deselect(_ subshape: SubShape) { - var s = selection.subshapes - s.remove(subshape) - selection = Selection(s) + select(subshape, scheme: .remove) } public func clearSelection() { selection = Selection() } - /// Replace `selection` wholesale. + /// Combine a whole candidate set with the current selection per `scheme`. /// - /// Used by `AreaSelection.swift` after combining a rectangle/lasso match - /// set with the existing selection per `SelectionScheme`; kept internal - /// since `select`/`deselect`/`clearSelection` are the intended public + /// The one place the scheme rules are written down: `select(_:scheme:)` passes a + /// single-element set, `AreaSelection.swift` passes a rectangle/lasso match set. + /// Internal, since `select`/`deselect`/`clearSelection` are the intended public /// mutation surface. + func applySelection(_ incoming: Set, scheme: SelectionScheme) { + let current = selection.subshapes + switch scheme { + case .replace: selection = Selection(incoming) + case .add: selection = Selection(current.union(incoming)) + case .remove: selection = Selection(current.subtracting(incoming)) + case .xor: selection = Selection(current.symmetricDifference(incoming)) + } + } + + /// Replace `selection` wholesale. + /// + /// Kept internal for the same reason as `applySelection(_:scheme:)`. func setSelection(_ newSelection: Selection) { selection = newSelection } @@ -354,6 +382,19 @@ public final class InteractiveContext: ObservableObject { entriesByID[object.id]?.bodyID } + /// Whether `bodyID` names a body this context displays as a selectable + /// `InteractiveObject`, that is, one added via `display(_:style:)`. + /// + /// Public so a host that composites its own bodies into `bodies` alongside this + /// context's (`OCCTSwiftCADKit.CADViewportService` does) can tell whose pick it is + /// looking at. Since the two share one selection, a host that clears the selection on an + /// unresolved pick has to leave this context's own picks alone, or it wipes a selection + /// it never owned. False for internal bodies (manipulator handles, dimensions), which + /// are not selectable objects. + public func displaysBody(withID bodyID: String) -> Bool { + entriesByBodyID[bodyID] != nil + } + /// The source `ViewportBody` for `object`, or nil if the object is not displayed /// or its tessellation produced no mesh. func sourceBody(for object: InteractiveObject) -> ViewportBody? { diff --git a/Sources/OCCTSwiftCADKit/CADViewportService.swift b/Sources/OCCTSwiftCADKit/CADViewportService.swift index 3b7a004..a08f77c 100644 --- a/Sources/OCCTSwiftCADKit/CADViewportService.swift +++ b/Sources/OCCTSwiftCADKit/CADViewportService.swift @@ -81,6 +81,23 @@ public final class CADViewportService { /// Every currently selected sub-shape (face, edge, or vertex), gated by `selectionModes`. /// + /// **A projection of `interactiveContext.selection`, not a second selection.** Since + /// OCCTSwiftInteraction#3 (phase 3 of ecosystem#43) this service holds no selection state + /// of its own: the state is the interactive context's `Set`, and this is that + /// set enriched into `PickedEntity` values for display. It is mirrored into stored state + /// rather than computed on read so SwiftUI observation still fires, exactly as `bodies` + /// mirrors `interactiveContext.bodies`; nothing writes it except `syncSelection`. + /// + /// Two consequences worth knowing: + /// + /// - **Order is by (body id, kind, ordinal)**, not by when each entry was selected: the + /// underlying state is a `Set`, so insertion order no longer exists to preserve. The + /// order is deterministic, just not chronological. + /// - **Whole-body selections do not appear here.** `SelectionMode.body` selects a + /// `SubShape.body` in the interactive context; read `interactiveContext.selection` (or + /// its `bodies` accessor) for those. This property is the sub-shape projection, and + /// `PickedEntity` has no whole-body case. + /// /// Empty if nothing is selected. A real viewport pick always replaces the whole selection /// (matching the point-pick behavior of `OCCTSwiftAIS` itself: scheme-based combination is /// for programmatic `select(_:scheme:)` calls, e.g. area selection); build multi-selection @@ -100,12 +117,26 @@ public final class CADViewportService { /// Which sub-shape kinds picking resolves. /// - /// Defaults to `[.face]`, matching this service's behavior before edge/vertex picking - /// existed; add `.edge`/`.vertex` to opt in. `.body` has no effect here (there is no - /// whole-body `PickedEntity` case); it exists on `SelectionMode` for - /// `OCCTSwiftAIS.InteractiveContext.selectionMode`, a separate, independent selection - /// system this service does not share state with. - public var selectionModes: Set = [.face] + /// **The same state as `interactiveContext.selectionMode`, not a copy of it.** Reading or + /// writing either one reads or writes the other; before OCCTSwiftInteraction#3 these were + /// two variables free to disagree, and by default they did (`[.face]` here, `[.body]` + /// there, in a service that owns both). + /// + /// Initialised to `[.face]` in `init`, matching this service's behavior before edge/vertex + /// picking existed, which overrides the interactive context's own `[.body]` default; add + /// `.edge`/`.vertex` to opt in. + /// + /// `.body` now does something: it selects a `SubShape.body` in the interactive context for + /// objects displayed there directly (`interactiveContext.display(_:style:)`), with AIS's + /// whole-body fallback on a face pick that fails to resolve. It still produces no + /// `PickedEntity`, because there is no whole-body case; see `selection`. + /// + /// Assigning a different set clears the selection, which is the interactive context's + /// documented behaviour for `selectionMode` and now applies here too. + public var selectionModes: Set { + get { interactiveContext.selectionMode } + set { interactiveContext.selectionMode = newValue } + } /// Currently selected face, or `nil` if nothing is picked or the current pick is an /// edge or vertex. @@ -134,6 +165,40 @@ public final class CADViewportService { private var selectionBodies: [_ViewportBody] = [] private var ownedBodyIDs: Set = [] private var bodiesSubscription: AnyCancellable? + private var selectionSubscription: AnyCancellable? + + // MARK: - Bridge to the interactive context's selection state + + /// A stable `InteractiveObject.id` per model body id. + /// + /// The interactive context names what a selection belongs to with an `InteractiveObject`, + /// while this service names it with a body id string, so driving that selection needs one + /// object per body. Only the **id** is cached, never the object: `InteractiveObject` + /// equality and hashing are id-only, so the `Shape` can be re-read from `bodyShapes` on + /// every construction (and can change under a cap-plane split) without disturbing set + /// membership. + /// + /// Deliberately not `interactiveContext.display(_:)`: that owns tessellation, and this + /// service tessellates its own bodies with its own transforms, caps and comparison state. + /// Registering them as context entries would also hand `updateSelectionVisuals` the + /// `triangleStyles` array that `setScalarField(_:forBody:)` paints, and the two would + /// overwrite each other. + private var bodyObjectIDs: [String: UUID] = [:] + + /// Reverse of `bodyObjectIDs`, for projecting a `SubShape` back to the body it names. + private var objectBodyIDs: [UUID: String] = [:] + + /// The enrichment computed for each currently selected sub-shape, keyed by the identity + /// the interactive context holds. + /// + /// A cache, not state: every key is a `SubShape` currently in + /// `interactiveContext.selection`, and `syncSelection` prunes it to exactly that set. It + /// exists because two `PickedFaceInfo` fields cannot be recovered from a `SubShapeRef` + /// after the fact: `scalarValue` for a `.perTriangle` field needs the triangle the pick + /// landed on, and `description` is formatted at pick time. A sub-shape selected some other + /// way (through the context directly, or by area selection) is enriched on demand instead, + /// and gets `scalarValue == nil` for a per-triangle field. + private var selectionInfo: [SubShape: PickedEntity] = [:] // MARK: - Durable identity (per loaded body) @@ -262,6 +327,10 @@ public final class CADViewportService { let controller = _ViewportController(configuration: configuration) self.controller = controller self.interactiveContext = InteractiveContext(viewport: controller) + // This service's historical default, applied to the now-shared mode set. The + // interactive context's own default is `[.body]`; an app that wants whole-body + // selection back sets `selectionModes` itself. + self.interactiveContext.selectionMode = [.face] controller.onPick = { [weak self] result in Task { @MainActor in self?.handlePick(result) @@ -272,6 +341,21 @@ public final class CADViewportService { .sink { [weak self] new in self?.bodies = new } + // The one path by which a selection change reaches this service, whoever made it: + // this service's own `select`/`clearSelection`, a direct `interactiveContext.select`, + // an area selection, or a `selectionMode` change clearing the selection. + // + // Deliberately NOT `.receive(on: RunLoop.main)`, unlike the `$bodies` sink above: this + // has to run synchronously so a caller reading `selection` immediately after + // `select(_:scheme:)` sees the result. That means it fires during `willSet`, when + // `interactiveContext.selection` still reads as the OLD value, so the new selection is + // taken from the emitted value rather than read back off the context. + self.selectionSubscription = interactiveContext.$selection + .sink { [weak self] newSelection in + MainActor.assumeIsolated { + self?.syncSelection(with: newSelection) + } + } } // MARK: - File Import @@ -393,6 +477,8 @@ public final class CADViewportService { faceIdentity.removeAll() edgeIdentity.removeAll() vertexIdentity.removeAll() + bodyObjectIDs.removeAll() + objectBodyIDs.removeAll() entities.removeAll() scalarFields.removeAll() lastScalarFieldBodyID = nil @@ -736,13 +822,8 @@ public final class CADViewportService { /// /// A full clean slate, equivalent to a fresh `CADViewportService`. public func removeAll() { - let hadSelection = !selection.isEmpty resetAllModelState() - if hadSelection { - clearSelection() // also calls rebuildBodies() - } else { - rebuildBodies() - } + clearSelection() // also calls rebuildBodies() } private func removeBodies(_ bodyIDs: [String]) { @@ -764,14 +845,26 @@ public final class CADViewportService { /// Drops only the selection entries that referenced a removed body, leaving everything /// else selected: the selection survives operations unrelated to it, and accurately /// reports (by no longer containing them) the entries that didn't. + /// + /// Prunes the interactive context's selection, which is where the state is. Keys on the + /// body's `InteractiveObject` rather than on `PickedEntity.bodyID` as it used to, which is + /// the same question asked in the vocabulary that now holds the answer, and it reaches + /// whole-body selections on the removed body too (a `PickedEntity` scan never could). private func pruneSelection(removingBodyIDs bodyIDs: [String]) { - let removed = Set(bodyIDs) - guard selection.contains(where: { removed.contains($0.bodyID) }) else { - rebuildBodies() - return + let removedObjectIDs = Set(bodyIDs.compactMap { bodyObjectIDs[$0] }) + for subShape in interactiveContext.selection.subshapes + where removedObjectIDs.contains(subShape.object.id) { + // Each of these fires the `$selection` sink, which re-projects and rebuilds. + interactiveContext.deselect(subShape) } - selection.removeAll { removed.contains($0.bodyID) } - rebuildSelectionHighlights() // also calls rebuildBodies() + for bodyID in bodyIDs { + if let objectID = bodyObjectIDs.removeValue(forKey: bodyID) { + objectBodyIDs.removeValue(forKey: objectID) + } + } + // Unconditional, because this always rebuilt the viewport even when it pruned nothing: + // its caller has just removed bodies that are still in the rendered array. + rebuildBodies() } /// Clears the active comparison if it referenced one of the just-removed entities. @@ -1004,13 +1097,19 @@ public final class CADViewportService { /// The scalar value at a resolved face pick, if a field is set on that body. /// /// `nil` domain matches `PickedFaceInfo.faceIndex`/`triangleIndex` per `ScalarField.Domain`. - private func scalarValue(forBody bodyID: String, faceIndex: Int, triangleIndex: Int) -> Double? + /// `triangleIndex` is `nil` when the face was not reached through a pick (an area + /// selection, or a selection made through `interactiveContext` directly), in which case a + /// `.perTriangle` field has nothing to sample and reports no value. A `.perFace` field is + /// unaffected: the face ordinal is enough. + private func scalarValue(forBody bodyID: String, faceIndex: Int, triangleIndex: Int?) + -> Double? { guard let field = scalarFields[bodyID] else { return nil } switch field.domain { case .perFace: return faceIndex >= 0 && faceIndex < field.values.count ? field.values[faceIndex] : nil case .perTriangle: + guard let triangleIndex else { return nil } return triangleIndex >= 0 && triangleIndex < field.values.count ? field.values[triangleIndex] : nil } @@ -1770,45 +1869,139 @@ public final class CADViewportService { // MARK: - Selection /// Clear the current selection (and any highlight bodies). + /// + /// Clears the interactive context's selection, which is the one selection there is, so + /// this also drops any whole-body or AIS-side entries, not just this service's sub-shape + /// projection. public func clearSelection() { - selection = [] + interactiveContext.clearSelection() + // Emptying `selection` itself is the `$selection` sink's job, and it has already run + // (synchronously) if anything changed. What is left here is the part this method has + // always done unconditionally, including when the selection was already empty: drop + // the highlight bodies and rebuild the viewport. selectionBodies = [] rebuildBodies() } - /// Adds, removes, or replaces `entity` in `selection` per `scheme`. + /// Adds, removes, or replaces `entity` in the selection per `scheme`. /// - /// Mirrors the exact combination semantics of `OCCTSwiftAIS.SelectionScheme` - /// (`.replace` assigns, `.add`/`.remove`/`.xor` combine against the current selection), - /// just applied to one entity here rather than a batch region match. Membership uses the - /// `Equatable` of `PickedEntity` itself (`uid`-preferring, so the same durable - /// face/edge/vertex is recognized as already-selected regardless of which ephemeral - /// ordinal it was picked at). + /// Delegates to `interactiveContext.select(_:scheme:)`, which holds the selection. + /// `SelectionScheme`'s semantics are the interactive context's own, the same ones + /// `selectRectangle`/`selectPolygon` area selection uses: `.replace` assigns, `.add` + /// inserts if absent, `.remove` drops it, `.xor` toggles it. + /// + /// Membership is `SubShapeRef`'s rule (the durable `uid` when both sides have one, else + /// the render-path ordinal), which is what `PickedEntity`'s own `Equatable` has always + /// mirrored, so the same durable face/edge/vertex is recognized as already-selected + /// regardless of which ephemeral ordinal it was picked at. + /// + /// An entity naming a body this service has not loaded still selects: it gets its own + /// `InteractiveObject` like any other body id, so a caller staging a pick by hand + /// (an escalation request, a test) behaves the same as a real one. public func select(_ entity: PickedEntity, scheme: SelectionScheme = .replace) { - switch scheme { - case .replace: - selection = [entity] - case .add: - if !selection.contains(entity) { - selection.append(entity) - } - case .remove: - selection.removeAll { $0 == entity } - case .xor: - if let index = selection.firstIndex(of: entity) { - selection.remove(at: index) - } else { - selection.append(entity) + let subShape = subShape(for: entity) + // Before delegating, so the `$selection` sink finds the enrichment already cached and + // does not have to rebuild it from the bare ref. + selectionInfo[subShape] = entity + interactiveContext.select(subShape, scheme: scheme) + } + + /// The interactive context's name for `entity`: its `SubShapeRef` plus the + /// `InteractiveObject` standing for the body it was picked on. + private func subShape(for entity: PickedEntity) -> SubShape { + let object = object(forBody: entity.bodyID, fallbackShape: entity.ref.shape) + switch entity { + case .face(let info): return .face(object, ref: info.ref) + case .edge(let info): return .edge(object, ref: info.ref) + case .vertex(let info): return .vertex(object, ref: info.ref) + } + } + + /// The `InteractiveObject` standing for `bodyID`, minted on first use and stable + /// thereafter. + /// + /// `fallbackShape` is only used for a body id this service has never loaded, where there + /// is no body shape to point at. It never affects identity: `InteractiveObject` compares + /// and hashes by `id` alone. + private func object(forBody bodyID: String, fallbackShape: OCCTSwift.Shape) + -> InteractiveObject + { + let id: UUID + if let existing = bodyObjectIDs[bodyID] { + id = existing + } else { + id = UUID() + bodyObjectIDs[bodyID] = id + objectBodyIDs[id] = bodyID + } + return InteractiveObject(id: id, shape: bodyShapes[bodyID] ?? fallbackShape) + } + + /// Re-projects the interactive context's selection into `selection` and rebuilds the + /// highlight bodies. + /// + /// Takes the new selection as an argument rather than reading `interactiveContext`, + /// because the `$selection` sink that drives it fires during `willSet`, when the context + /// still reports the previous value. + private func syncSelection(with newSelection: Selection) { + let subShapes = newSelection.subshapes + let projected = + subShapes + .compactMap { pickedEntity(for: $0) } + .sorted(by: Self.selectionOrder) + selectionInfo = selectionInfo.filter { subShapes.contains($0.key) } + guard projected != selection else { return } + selection = projected + rebuildSelectionHighlights() // also calls rebuildBodies() + } + + /// Deterministic ordering for `selection`: body id, then kind, then render-path ordinal. + /// + /// The underlying state is a `Set`, so there is no insertion order left to + /// preserve; an unordered projection would make `selection` differ run to run. + private static func selectionOrder(_ lhs: PickedEntity, _ rhs: PickedEntity) -> Bool { + func rank(_ entity: PickedEntity) -> Int { + switch entity { + case .face: return 0 + case .edge: return 1 + case .vertex: return 2 } } - rebuildSelectionHighlights() + return (lhs.bodyID, rank(lhs), lhs.ref.ordinal) + < (rhs.bodyID, rank(rhs), rhs.ref.ordinal) } + /// The enrichment for one selected sub-shape: the value cached when this service resolved + /// or was handed the pick, else built on demand. + /// + /// `nil` for a `.body` sub-shape (no whole-body `PickedEntity` case), for a body this + /// service does not have geometry for, and for anything whose enrichment fails. + private func pickedEntity(for subShape: SubShape) -> PickedEntity? { + if let cached = selectionInfo[subShape] { return cached } + guard let bodyID = objectBodyIDs[subShape.object.id] else { return nil } + switch subShape { + case .body: + return nil + case .face(_, let ref): + return enrichFace(ref: ref, bodyID: bodyID, triangleIndex: nil).map(PickedEntity.face) + case .edge(_, let ref): + return enrichEdge(ref: ref, bodyID: bodyID).map(PickedEntity.edge) + case .vertex(_, let ref): + return enrichVertex(ref: ref, bodyID: bodyID, renderPosition: nil).map( + PickedEntity.vertex) + } + } + + /// Renamed to `selectionMeasurements` in OCCTSwiftInteraction#3, with the type it returns. + @available(*, deprecated, renamed: "selectionMeasurements") + public var selectionSummary: SelectionMeasurements? { selectionMeasurements } + /// Aggregate measures over `selection`: count by kind, total face area, total edge /// length, and combined bounds. /// - /// `nil` when nothing is selected. - public var selectionSummary: SelectionSummary? { + /// `nil` when nothing is selected. Whole-body selections do not contribute, for the same + /// reason they do not appear in `selection`. + public var selectionMeasurements: SelectionMeasurements? { guard !selection.isEmpty else { return nil } var faceCount = 0 @@ -1831,6 +2024,10 @@ public final class CADViewportService { case .face(let info): faceCount += 1 totalArea += info.area + // Re-derives the face's own 3D bounding box rather than reading `info.bounds`, + // which is deliberate and not a missed reuse: `FaceBounds` is XY only and + // `Float`, while this aggregate is 3D and `Double`. The edge and vertex + // branches below read their cached values because those already are 3D. if let face = Face(info.shape), let faceBounds = face.bounds { absorb(faceBounds) } @@ -1866,7 +2063,7 @@ public final class CADViewportService { maxX: maxPt.x, maxY: maxPt.y, maxZ: maxPt.z ) : nil - return SelectionSummary( + return SelectionMeasurements( faceCount: faceCount, edgeCount: edgeCount, vertexCount: vertexCount, @@ -1876,8 +2073,28 @@ public final class CADViewportService { ) } - private func handlePick(_ result: _PickResult?) { - guard let result, let entity = resolveEntityPick(result) else { + /// `internal` rather than `private`, for the same reason as `resolveFacePick` and its + /// siblings: so a test can drive the whole pick path (mode gate, ownership check, + /// resolution, selection) with a synthesised `PickResult` instead of only its middle. + /// `controller.onPick` is the only production caller. + func handlePick(_ result: _PickResult?) { + guard let result else { + // Empty space deselects, which is this service's contract and now applies to the + // whole shared selection, including anything held for an object displayed + // directly into the interactive context. + clearSelection() + return + } + + // A pick on a body the interactive context displays itself belongs to that context, + // which resolves it through its own `handlePick` into the same selection this service + // now reads. Returning here rather than falling through to `clearSelection()` is what + // stops this service from wiping a selection it never owned; before + // OCCTSwiftInteraction#3 the two selections were independent and the question could + // not arise. + guard !interactiveContext.displaysBody(withID: result.bodyID) else { return } + + guard let entity = resolveEntityPick(result) else { clearSelection() return } @@ -1930,11 +2147,20 @@ public final class CADViewportService { else { return nil } + return enrichFace(ref: ref, bodyID: bodyID, triangleIndex: triangleIndex) + } - let faceShape = ref.shape - let faceIndex = ref.ordinal - let uid = ref.uid - guard let face = Face(faceShape) else { return nil } + /// The presentation half of a face pick: everything `PickedFaceInfo` carries beyond the + /// identity in `ref`. + /// + /// Split out of `resolveFacePick` so a sub-shape that reached the selection some other way + /// (through `interactiveContext` directly, or by area selection) is enriched by the same + /// code rather than a second copy of it. `triangleIndex` is `nil` for those, which only + /// affects a `.perTriangle` scalar field: there is no triangle to sample. + private func enrichFace(ref: SubShapeRef, bodyID: String, triangleIndex: Int?) + -> PickedFaceInfo? + { + guard let face = Face(ref.shape) else { return nil } let isHoriz = face.isHorizontal() let isVert = face.isVertical() @@ -1958,9 +2184,7 @@ public final class CADViewportService { let desc = "\(typeStr) face\(zStr), \(sizeStr)mm" return PickedFaceInfo( - shape: faceShape, - uid: uid, - faceIndex: faceIndex, + ref: ref, bodyID: bodyID, isHorizontal: isHoriz, isVertical: isVert, @@ -1969,7 +2193,7 @@ public final class CADViewportService { area: faceArea, description: desc, scalarValue: scalarValue( - forBody: bodyID, faceIndex: faceIndex, triangleIndex: triangleIndex) + forBody: bodyID, faceIndex: ref.ordinal, triangleIndex: triangleIndex) ) } @@ -1998,11 +2222,14 @@ public final class CADViewportService { else { return nil } + return enrichEdge(ref: ref, bodyID: bodyID) + } - let edgeShape = ref.shape - let edgeIndex = ref.ordinal - let uid = ref.uid - guard let edge = Edge(edgeShape) else { return nil } + /// The presentation half of an edge pick. + /// + /// See `enrichFace(ref:bodyID:triangleIndex:)`. + private func enrichEdge(ref: SubShapeRef, bodyID: String) -> PickedEdgeInfo? { + guard let edge = Edge(ref.shape) else { return nil } let endpoints = edge.endpoints let typeStr: String @@ -2020,9 +2247,7 @@ public final class CADViewportService { let desc = "\(typeStr) edge, \(String(format: "%.1f", edge.length))mm" return PickedEdgeInfo( - shape: edgeShape, - uid: uid, - edgeIndex: edgeIndex, + ref: ref, bodyID: bodyID, curveType: edge.curveType, length: edge.length, @@ -2060,26 +2285,30 @@ public final class CADViewportService { else { return nil } - - let vertexShape = ref.shape - let vertexIndex = ref.ordinal - let uid = ref.uid - // In range whenever the resolver returned a ref: it bounds `pointIndex` by the // `pointCount` passed above, which is this array's own count. - let renderPosition = body.vertices[pointIndex] - let position = - vertexShape.vertices().first - ?? SIMD3( - Double(renderPosition.x), Double(renderPosition.y), Double(renderPosition.z) - ) + return enrichVertex(ref: ref, bodyID: bodyID, renderPosition: body.vertices[pointIndex]) + } + + /// The presentation half of a vertex pick. + /// + /// See `enrichFace(ref:bodyID:triangleIndex:)`. + /// + /// `renderPosition` is the rendered point the pick landed on, used only when the resolved + /// `Shape` yields no vertex of its own; `nil` for a vertex that did not come from a pick, + /// which then simply has no fallback. + private func enrichVertex(ref: SubShapeRef, bodyID: String, renderPosition: SIMD3?) + -> PickedVertexInfo? + { + let fallback = renderPosition.map { + SIMD3(Double($0.x), Double($0.y), Double($0.z)) + } + guard let position = ref.shape.vertices().first ?? fallback else { return nil } let desc = String( format: "Vertex at (%.1f, %.1f, %.1f)mm", position.x, position.y, position.z) return PickedVertexInfo( - shape: vertexShape, - uid: uid, - vertexIndex: vertexIndex, + ref: ref, bodyID: bodyID, position: position, description: desc diff --git a/Sources/OCCTSwiftCADKit/PickedEntity.swift b/Sources/OCCTSwiftCADKit/PickedEntity.swift index f5cb8f3..e8eeabb 100644 --- a/Sources/OCCTSwiftCADKit/PickedEntity.swift +++ b/Sources/OCCTSwiftCADKit/PickedEntity.swift @@ -1,12 +1,19 @@ import Foundation import OCCTSwift +import OCCTSwiftTools import simd /// A pick result, generalised over which kind of sub-shape was hit. /// -/// Face picks carry the existing `PickedFaceInfo`; edge and vertex picks carry their own info -/// types alongside it, all sharing the same durable-identity shape (`shape`/`uid`, plus an -/// ephemeral render-path ordinal) established for faces in `PickedFaceInfo`. +/// The presentation half of a selection: each case wraps an `OCCTSwiftTools.SubShapeRef` +/// (the identity, minted by `SubShapePickResolver`) together with the geometry +/// `CADViewportService` reads off it for display. The selection *state* itself lives in +/// `OCCTSwiftAIS.InteractiveContext` as `SubShape` values; see `CADViewportService.selection`. +/// +/// There is deliberately no `.body` case. Whole-body selection is a `SubShape.body` in the +/// interactive context, where AIS's whole-body fallback and its body-level highlight already +/// live; this enum names the sub-shape kinds this service enriches. See the bakeoff on +/// OCCTSwiftInteraction#3. public enum PickedEntity: Sendable, Equatable { case face(PickedFaceInfo) case edge(PickedEdgeInfo) @@ -23,28 +30,46 @@ public enum PickedEntity: Sendable, Equatable { case .vertex(let info): return info.bodyID } } + + /// The identity half of this pick. + /// + /// The same value the `OCCTSwiftAIS` selection state holds for it. + public var ref: SubShapeRef { + switch self { + case .face(let info): return info.ref + case .edge(let info): return info.ref + case .vertex(let info): return info.ref + } + } } /// Information about an edge picked in the viewport. /// -/// `shape` and `uid` are the durable identity of the pick, captured at pick time from the -/// picked body's `EdgeIdentityTable`. `edgeIndex` is the ephemeral render-path ordinal, -/// valid only against the `ViewportBody`/`edgeIndices` it was minted from. +/// A presentation type built from `OCCTSwiftTools.SubShapeRef`, exactly like `PickedFaceInfo`: +/// `ref` is the identity, captured at pick time from the picked body's `EdgeIdentityTable`, and +/// the rest is enrichment. `edgeIndex` is the ephemeral render-path ordinal, valid only against +/// the `ViewportBody`/`edgeIndices` it was minted from. public struct PickedEdgeInfo: Sendable { + /// The identity of this pick. + /// + /// See `PickedFaceInfo.ref`. + public let ref: SubShapeRef + /// The picked edge, as the exact `Shape` (wrapping a `TopoDS_Edge`) it was extracted from. /// /// Construct an `Edge` from it (`Edge(shape)`) for edge-specific queries. - public let shape: OCCTSwift.Shape + public var shape: OCCTSwift.Shape { ref.shape } /// Durable handle into the picked body's `BRepGraph`, when the graph was available at /// pick time. `nil` if graph construction failed for this body. - public let uid: BRepGraph.GraphUID? + public var uid: BRepGraph.GraphUID? { ref.uid } /// Render-path ordinal into this body's `edgeIndices`. /// /// Ephemeral: do not use it to re-derive the edge via `loadedShape.edges()[edgeIndex]`; /// use `shape` instead. - public let edgeIndex: Int + public var edgeIndex: Int { ref.ordinal } + public let bodyID: String public let curveType: OCCTSwift.Edge.CurveType public let length: Double @@ -53,9 +78,7 @@ public struct PickedEdgeInfo: Sendable { public let description: String public init( - shape: OCCTSwift.Shape, - uid: BRepGraph.GraphUID? = nil, - edgeIndex: Int, + ref: SubShapeRef, bodyID: String, curveType: OCCTSwift.Edge.CurveType, length: Double, @@ -63,9 +86,7 @@ public struct PickedEdgeInfo: Sendable { endPoint: SIMD3, description: String ) { - self.shape = shape - self.uid = uid - self.edgeIndex = edgeIndex + self.ref = ref self.bodyID = bodyID self.curveType = curveType self.length = length @@ -73,68 +94,103 @@ public struct PickedEdgeInfo: Sendable { self.endPoint = endPoint self.description = description } + + /// Source-compatible convenience for the pre-OCCTSwiftInteraction#3 shape of this type. + public init( + shape: OCCTSwift.Shape, + uid: BRepGraph.GraphUID? = nil, + edgeIndex: Int, + bodyID: String, + curveType: OCCTSwift.Edge.CurveType, + length: Double, + startPoint: SIMD3, + endPoint: SIMD3, + description: String + ) { + self.init( + ref: SubShapeRef(shape: shape, uid: uid, ordinal: edgeIndex), + bodyID: bodyID, + curveType: curveType, + length: length, + startPoint: startPoint, + endPoint: endPoint, + description: description + ) + } } extension PickedEdgeInfo: Equatable { - /// Hand-written for the same reason as `PickedFaceInfo.==`: `Shape` has no usable - /// `Equatable`, and `uid` (when present on both sides) is authoritative over the - /// ephemeral ordinal. + /// Hand-written for the same reason as `PickedFaceInfo.==`, and by the same rule. public static func == (lhs: PickedEdgeInfo, rhs: PickedEdgeInfo) -> Bool { - switch (lhs.uid, rhs.uid) { - case (let l?, let r?): return l == r - case (nil, nil): return lhs.edgeIndex == rhs.edgeIndex && lhs.bodyID == rhs.bodyID - default: return false - } + isSamePick(lhs.ref, lhs.bodyID, rhs.ref, rhs.bodyID) } } /// Information about a vertex picked in the viewport. /// -/// `shape` and `uid` are the durable identity of the pick, captured at pick time from the -/// picked body's `VertexIdentityTable`. `vertexIndex` is the ephemeral render-path ordinal, -/// valid only against the `ViewportBody`/`vertexIndices` it was minted from. +/// A presentation type built from `OCCTSwiftTools.SubShapeRef`, exactly like `PickedFaceInfo`: +/// `ref` is the identity, captured at pick time from the picked body's `VertexIdentityTable`. +/// `vertexIndex` is the ephemeral render-path ordinal, valid only against the +/// `ViewportBody`/`vertexIndices` it was minted from. public struct PickedVertexInfo: Sendable { - /// The picked vertex, as the exact `Shape` (wrapping a `TopoDS_Vertex`) it was extracted from. + /// The identity of this pick. + /// + /// See `PickedFaceInfo.ref`. + public let ref: SubShapeRef + + /// The picked vertex, as the exact `Shape` (wrapping a `TopoDS_Vertex`) it was extracted + /// from. /// /// OCCTSwift exposes vertices positionally rather than as their own class: use /// `position`, or `shape.vertices().first`, for its world-space location. - public let shape: OCCTSwift.Shape + public var shape: OCCTSwift.Shape { ref.shape } /// Durable handle into the picked body's `BRepGraph`, when the graph was available at /// pick time. `nil` if graph construction failed for this body. - public let uid: BRepGraph.GraphUID? + public var uid: BRepGraph.GraphUID? { ref.uid } /// Render-path ordinal into this body's `vertexIndices`. /// /// Ephemeral. - public let vertexIndex: Int + public var vertexIndex: Int { ref.ordinal } + public let bodyID: String public let position: SIMD3 public let description: String public init( - shape: OCCTSwift.Shape, - uid: BRepGraph.GraphUID? = nil, - vertexIndex: Int, + ref: SubShapeRef, bodyID: String, position: SIMD3, description: String ) { - self.shape = shape - self.uid = uid - self.vertexIndex = vertexIndex + self.ref = ref self.bodyID = bodyID self.position = position self.description = description } + + /// Source-compatible convenience for the pre-OCCTSwiftInteraction#3 shape of this type. + public init( + shape: OCCTSwift.Shape, + uid: BRepGraph.GraphUID? = nil, + vertexIndex: Int, + bodyID: String, + position: SIMD3, + description: String + ) { + self.init( + ref: SubShapeRef(shape: shape, uid: uid, ordinal: vertexIndex), + bodyID: bodyID, + position: position, + description: description + ) + } } extension PickedVertexInfo: Equatable { + /// Hand-written for the same reason as `PickedFaceInfo.==`, and by the same rule. public static func == (lhs: PickedVertexInfo, rhs: PickedVertexInfo) -> Bool { - switch (lhs.uid, rhs.uid) { - case (let l?, let r?): return l == r - case (nil, nil): return lhs.vertexIndex == rhs.vertexIndex && lhs.bodyID == rhs.bodyID - default: return false - } + isSamePick(lhs.ref, lhs.bodyID, rhs.ref, rhs.bodyID) } } diff --git a/Sources/OCCTSwiftCADKit/PickedFaceInfo.swift b/Sources/OCCTSwiftCADKit/PickedFaceInfo.swift index 722b44c..49d67df 100644 --- a/Sources/OCCTSwiftCADKit/PickedFaceInfo.swift +++ b/Sources/OCCTSwiftCADKit/PickedFaceInfo.swift @@ -1,35 +1,65 @@ import Foundation import OCCTSwift +import OCCTSwiftTools import simd +/// The one identity rule for a picked entity, shared by `PickedFaceInfo`, `PickedEdgeInfo` and +/// `PickedVertexInfo`. +/// +/// `SubShapeRef.==` (the durable `uid` when both sides have one, else the ephemeral ordinal), +/// qualified by the body the pick landed on, which a bare ref does not carry. In `OCCTSwiftAIS` +/// that qualification comes from the `InteractiveObject` in the enclosing `SubShape`; here the +/// info types carry a `bodyID` instead, so it has to be applied explicitly. +/// +/// Written once rather than three times: before OCCTSwiftInteraction#3 each of the three info +/// types carried its own hand-rolled copy of this, each commented "mirrors +/// `OCCTSwiftAIS.SubShapeRef.==` exactly", which is three chances for one rule to drift. +func isSamePick(_ lhs: SubShapeRef, _ lhsBodyID: String, _ rhs: SubShapeRef, _ rhsBodyID: String) + -> Bool +{ + guard lhs == rhs else { return false } + // `SubShapeRef.==` compares uids when both sides have one, which is already body-agnostic + // and durable. It falls back to the ordinal only when neither side has a uid, and an + // ordinal means nothing across bodies, so that case needs the body checked too. + return lhs.uid != nil || lhsBodyID == rhsBodyID +} + /// Information about a face picked in the viewport. /// -/// `shape` and `uid` are the durable identity of the pick, captured once at pick time -/// from the picked body's `FaceIdentityTable` rather than re-derived later. `faceIndex` -/// is the ephemeral render-path ordinal that produced the pick, valid only against the -/// `ViewportBody`/`CADBodyMetadata` it was minted from. Once a face is shared between two -/// shells, `loadedShape.faces()[faceIndex]` and a `BRepGraph`'s node ordering diverge (the -/// graph dedups the shared face to one node; the render-path traversal counts it once per -/// shell), so re-deriving the face from `faceIndex` alone can silently name the wrong one. -/// Use `shape`, constructing a `Face` from it (`Face(info.shape)`) for face-specific queries, -/// rather than subscripting `loadedShape.faces()`. +/// A presentation type built from `OCCTSwiftTools.SubShapeRef`: `ref` is the identity half, +/// minted by `SubShapePickResolver`, and everything else on here is the enrichment +/// `CADViewportService` adds for its UI (`bounds`, `zLevel`, `area`, `description`, +/// `scalarValue`). `shape`, `uid` and `faceIndex` forward to `ref` rather than duplicating it. +/// +/// `faceIndex` is the ephemeral render-path ordinal that produced the pick, valid only against +/// the `ViewportBody`/`CADBodyMetadata` it was minted from. Once a face is shared between two +/// shells, `loadedShape.faces()[faceIndex]` and a `BRepGraph`'s node ordering diverge (the graph +/// dedups the shared face to one node; the render-path traversal counts it once per shell), so +/// re-deriving the face from `faceIndex` alone can silently name the wrong one. Use `shape`, +/// constructing a `Face` from it (`Face(info.shape)`) for face-specific queries, rather than +/// subscripting `loadedShape.faces()`. public struct PickedFaceInfo: Sendable { + /// The identity of this pick: the exact `Shape` the face was tessellated from, its durable + /// `BRepGraph.GraphUID` when a graph was available, and the render-path ordinal. + public let ref: SubShapeRef + /// The picked face, as the exact `Shape` (wrapping a `TopoDS_Face`) it was tessellated from. /// /// Construct a `Face` from it (`Face(shape)`) for face-specific queries such as area or /// normal. - public let shape: OCCTSwift.Shape + public var shape: OCCTSwift.Shape { ref.shape } /// Durable handle into the picked body's `BRepGraph`, when the graph was available at /// pick time. `nil` if graph construction failed for this body (a pathological shape): /// such a pick has nothing durable to resolve forward through a later rebuild. - public let uid: BRepGraph.GraphUID? + public var uid: BRepGraph.GraphUID? { ref.uid } /// Render-path ordinal into this body's tessellation (`CADBodyMetadata.faceIndices`). /// /// Ephemeral: do not use it to re-derive the face via `loadedShape.faces()[faceIndex]`; /// use `shape` instead. - public let faceIndex: Int + public var faceIndex: Int { ref.ordinal } + public let bodyID: String public let isHorizontal: Bool public let isVertical: Bool @@ -40,13 +70,13 @@ public struct PickedFaceInfo: Sendable { /// This face's value from the `ScalarField` set on its body (`setScalarField(_:forBody:)`), /// if any: resolved at pick time from the field's own domain (`.perFace` by `faceIndex`, - /// `.perTriangle` by the picked triangle). `nil` when no field is set on this body. + /// `.perTriangle` by the picked triangle). `nil` when no field is set on this body, and + /// also `nil` for a `.perTriangle` field when this info was built from a selection that + /// did not come from a pick (an area selection, say), since no triangle was involved. public let scalarValue: Double? public init( - shape: OCCTSwift.Shape, - uid: BRepGraph.GraphUID? = nil, - faceIndex: Int, + ref: SubShapeRef, bodyID: String, isHorizontal: Bool, isVertical: Bool, @@ -56,9 +86,7 @@ public struct PickedFaceInfo: Sendable { description: String, scalarValue: Double? = nil ) { - self.shape = shape - self.uid = uid - self.faceIndex = faceIndex + self.ref = ref self.bodyID = bodyID self.isHorizontal = isHorizontal self.isVertical = isVertical @@ -68,24 +96,45 @@ public struct PickedFaceInfo: Sendable { self.description = description self.scalarValue = scalarValue } + + /// Source-compatible convenience for the pre-OCCTSwiftInteraction#3 shape of this type, + /// which stored `shape`/`uid`/`faceIndex` separately instead of a `SubShapeRef`. + public init( + shape: OCCTSwift.Shape, + uid: BRepGraph.GraphUID? = nil, + faceIndex: Int, + bodyID: String, + isHorizontal: Bool, + isVertical: Bool, + bounds: FaceBounds, + zLevel: Float?, + area: Double, + description: String, + scalarValue: Double? = nil + ) { + self.init( + ref: SubShapeRef(shape: shape, uid: uid, ordinal: faceIndex), + bodyID: bodyID, + isHorizontal: isHorizontal, + isVertical: isVertical, + bounds: bounds, + zLevel: zLevel, + area: area, + description: description, + scalarValue: scalarValue + ) + } } extension PickedFaceInfo: Equatable { - /// Hand-written: `OCCTSwift.Shape` has no `IsSame`-respecting `Equatable` conformance to - /// piggyback on, so `shape` is excluded. + /// Identity is `isSamePick`, the one rule shared with the edge and vertex info types. /// - /// Identity follows `uid` when both sides have one (the durable handle, which two picks - /// of the same shared face can carry even with different `faceIndex`/`bodyID` ordinals, - /// see the shared-face-between-shells regression test), falling back to `faceIndex` + - /// `bodyID` only when neither side has a `uid`. Mirrors `OCCTSwiftAIS.SubShapeRef.==` - /// exactly; the descriptive fields (`bounds`, `area`, etc.) are derived deterministically - /// from the same face and so don't need to participate. + /// Hand-written because `OCCTSwift.Shape` has no `IsSame`-respecting `Equatable` + /// conformance to piggyback on, so a synthesised `==` would drag the descriptive fields + /// (`bounds`, `area`, and the rest) in through it. They are derived deterministically from + /// the same face anyway, so they have nothing to add. public static func == (lhs: PickedFaceInfo, rhs: PickedFaceInfo) -> Bool { - switch (lhs.uid, rhs.uid) { - case (let l?, let r?): return l == r - case (nil, nil): return lhs.faceIndex == rhs.faceIndex && lhs.bodyID == rhs.bodyID - default: return false - } + isSamePick(lhs.ref, lhs.bodyID, rhs.ref, rhs.bodyID) } } diff --git a/Sources/OCCTSwiftCADKit/SelectionSummary.swift b/Sources/OCCTSwiftCADKit/SelectionMeasurements.swift similarity index 52% rename from Sources/OCCTSwiftCADKit/SelectionSummary.swift rename to Sources/OCCTSwiftCADKit/SelectionMeasurements.swift index ecf6975..8f5c92f 100644 --- a/Sources/OCCTSwiftCADKit/SelectionSummary.swift +++ b/Sources/OCCTSwiftCADKit/SelectionMeasurements.swift @@ -2,9 +2,17 @@ import Foundation import OCCTSwift import simd -/// Aggregate measures over a multi-selection (`CADViewportService.selection`). `nil` when -/// the selection is empty: there's nothing to summarize. -public struct SelectionSummary: Sendable, Equatable { +/// Aggregate measures over a multi-selection (`CADViewportService.selectionMeasurements`). +/// `nil` when the selection is empty: there is nothing to measure. +/// +/// Named `SelectionSummary` until OCCTSwiftInteraction#3. `OCCTSwiftUXKit` has an unrelated +/// public `SelectionSummary` of its own (the selection pill's caption and SF Symbol, built from +/// `EntityRef` values, with no OCCT dependency at all), and the two shared nothing but the +/// spelling: no field, no input, no consumer in common. The bakeoff on that issue found nothing +/// to merge, so the collision is resolved by naming, the same way OCCTSwiftViewport's +/// `SelectionFilter` was in phase 1 of ecosystem#43. This is the measurement half; that one is +/// the caption half. +public struct SelectionMeasurements: Sendable, Equatable { public let faceCount: Int public let edgeCount: Int public let vertexCount: Int @@ -36,3 +44,8 @@ public struct SelectionSummary: Sendable, Equatable { self.bounds = bounds } } + +/// Renamed to `SelectionMeasurements` in OCCTSwiftInteraction#3, to stop colliding by name with +/// the unrelated `OCCTSwiftUXKit.SelectionSummary`. +@available(*, deprecated, renamed: "SelectionMeasurements") +public typealias SelectionSummary = SelectionMeasurements diff --git a/Tests/OCCTSwiftAISTests/SelectionTests.swift b/Tests/OCCTSwiftAISTests/SelectionTests.swift index e9e3c74..7c3b69a 100644 --- a/Tests/OCCTSwiftAISTests/SelectionTests.swift +++ b/Tests/OCCTSwiftAISTests/SelectionTests.swift @@ -1,4 +1,5 @@ import OCCTSwift +import OCCTSwiftViewport import Testing @testable import OCCTSwiftAIS @@ -66,3 +67,75 @@ struct SelectionTests { #expect(s.faces.count == 1) } } + +/// The scheme parameter added to `InteractiveContext.select` in +/// OCCTSwiftInteraction#3 (phase 3 of ecosystem#43), when `OCCTSwiftCADKit` stopped keeping a +/// parallel selection and its four-scheme `select(_:scheme:)` merged into this one. +@Suite("Selection schemes on InteractiveContext") +@MainActor +struct InteractiveContextSchemeTests { + + private func makeFixture() throws -> (InteractiveContext, SubShape, SubShape) { + let ctx = InteractiveContext(viewport: ViewportController()) + let shape = try #require(Shape.box(width: 4, height: 4, depth: 4)) + let object = InteractiveObject(shape: shape) + let a = SubShape.face(object, ref: try faceRef(object, 0)) + let b = SubShape.face(object, ref: try faceRef(object, 1)) + return (ctx, a, b) + } + + @Test("select(_:scheme:) implements replace/add/remove/xor") + func t_selectScheme_implementsAllFour() throws { + let (ctx, a, b) = try makeFixture() + + ctx.select(a, scheme: .replace) + #expect(ctx.selection.subshapes == [a]) + + ctx.select(b, scheme: .add) + #expect(ctx.selection.subshapes == [a, b]) + + ctx.select(a, scheme: .add) + #expect(ctx.selection.count == 2, "add is idempotent") + + ctx.select(a, scheme: .remove) + #expect(ctx.selection.subshapes == [b]) + + ctx.select(a, scheme: .xor) + #expect(ctx.selection.subshapes == [a, b]) + + ctx.select(a, scheme: .xor) + #expect(ctx.selection.subshapes == [b]) + + ctx.select(a, scheme: .replace) + #expect(ctx.selection.subshapes == [a], "replace discards the rest") + } + + /// The reason `select(_:scheme:)` has no default value for `scheme`: giving the existing + /// one-argument `select` a defaulted `.replace` would have retuned every existing call + /// site from add to replace, silently. + @Test("select(_:) still means add, not replace") + func t_selectWithoutScheme_stillMeansAdd() throws { + let (ctx, a, b) = try makeFixture() + + ctx.select(a) + ctx.select(b) + #expect(ctx.selection.subshapes == [a, b]) + + ctx.deselect(a) + #expect(ctx.selection.subshapes == [b]) + } + + @Test("displaysBody(withID:) reports only objects this context displays") + func t_displaysBody_reportsDisplayedObjectsOnly() throws { + let ctx = InteractiveContext(viewport: ViewportController()) + let shape = try #require(Shape.box(width: 4, height: 4, depth: 4)) + let object = ctx.display(shape) + + let bodyID = try #require(ctx.bodyID(for: object)) + #expect(ctx.displaysBody(withID: bodyID)) + #expect(!ctx.displaysBody(withID: "some.other.body")) + + ctx.remove(object) + #expect(!ctx.displaysBody(withID: bodyID)) + } +} diff --git a/Tests/OCCTSwiftCADKitTests/SharedSelectionTests.swift b/Tests/OCCTSwiftCADKitTests/SharedSelectionTests.swift new file mode 100644 index 0000000..b7cc204 --- /dev/null +++ b/Tests/OCCTSwiftCADKitTests/SharedSelectionTests.swift @@ -0,0 +1,301 @@ +import OCCTSwift +import OCCTSwiftAIS +import OCCTSwiftTools +import OCCTSwiftViewport +import Testing +import simd + +@testable import OCCTSwiftCADKit + +/// One selection store, not two synchronised ones. +/// +/// Phase 3 of ecosystem#43 (OCCTSwiftInteraction#3): `CADViewportService` stopped keeping a +/// selection alongside the `InteractiveContext` it already owned, and started driving that one +/// instead. These tests hold down the property that makes it one system rather than two +/// synchronised ones: there is a single store, and every route into it is visible from both +/// sides. +@Suite("Shared selection state") +struct SharedSelectionTests { + + @MainActor + private func loadedService(id: String = "box") -> (CADViewportService, PickedEntity)? { + guard let box = Shape.box(width: 10, height: 8, depth: 6) else { return nil } + let service = CADViewportService() + service.selectionModes = [.face, .edge, .vertex] + service.load(box, id: id) + guard let pick = service.resolveFacePick(bodyID: id, triangleIndex: 0) else { return nil } + return (service, .face(pick)) + } + + /// The headline. + /// + /// `selectionModes` is not a copy of `interactiveContext.selectionMode`, it IS it. + /// Before this change the two defaulted to different values in the same service, + /// which is what "a separate, independent selection system this service does not share + /// state with" meant in practice. + @MainActor + @Test("selectionModes and interactiveContext.selectionMode are one setting, not two") + func selectionModesIsTheContextsSelectionMode() { + let service = CADViewportService() + + #expect(service.selectionModes == [.face], "this service's own default wins at init") + #expect(service.interactiveContext.selectionMode == [.face]) + + service.selectionModes = [.face, .edge] + #expect(service.interactiveContext.selectionMode == [.face, .edge]) + + service.interactiveContext.selectionMode = [.vertex, .body] + #expect(service.selectionModes == [.vertex, .body]) + } + + @MainActor + @Test("A CADKit selection is the interactive context's selection") + func selectingThroughTheServiceUpdatesTheContext() { + guard let (service, entity) = loadedService() else { + Issue.record("fixture setup failed") + return + } + + service.select(entity) + + #expect(service.selection == [entity]) + #expect(service.interactiveContext.selection.count == 1) + guard let subShape = service.interactiveContext.selection.subshapes.first else { + Issue.record("the context holds no sub-shape for a selection made through CADKit") + return + } + guard case .face(_, let ref) = subShape else { + Issue.record("expected a face sub-shape") + return + } + #expect(ref == entity.ref, "same identity, not a re-derived one") + } + + @MainActor + @Test("Clearing through the interactive context clears the service's selection") + func clearingThroughTheContextClearsTheService() { + guard let (service, entity) = loadedService() else { + Issue.record("fixture setup failed") + return + } + service.select(entity) + #expect(!service.selection.isEmpty) + + service.interactiveContext.clearSelection() + + #expect( + service.selection.isEmpty, + "the service projects the context's selection, so clearing there clears here") + #expect( + !service.interactiveContext.bodies.contains { $0.id == "selection_highlight_face" }, + "the highlight body must go with it") + } + + /// Selecting through the context directly (an app's own code, or area selection) reaches + /// the service's projection, enriched on demand rather than only when the service resolved + /// the pick itself. + @MainActor + @Test("Selecting through the interactive context enriches into the service's selection") + func selectingThroughTheContextEnrichesOnDemand() { + guard let (service, entity) = loadedService() else { + Issue.record("fixture setup failed") + return + } + service.select(entity) + guard let subShape = service.interactiveContext.selection.subshapes.first else { + Issue.record("no sub-shape to re-select") + return + } + service.interactiveContext.clearSelection() + #expect(service.selection.isEmpty) + + // Nothing cached now: this has to go through the on-demand enrichment path. + service.interactiveContext.select(subShape) + + #expect(service.selection.count == 1) + guard case .face(let info)? = service.selection.first else { + Issue.record("expected an enriched face") + return + } + #expect(info.bodyID == "box") + #expect(info.description.hasSuffix("mm"), "enrichment ran, not just identity") + #expect(info.area > 0) + } + + /// Changing the mode set clears the selection. + /// + /// That is the interactive context's documented behaviour for `selectionMode`, and now + /// that this service shares that state it inherits the behaviour too. + @MainActor + @Test("Changing selectionModes clears the selection") + func changingSelectionModesClearsTheSelection() { + guard let (service, entity) = loadedService() else { + Issue.record("fixture setup failed") + return + } + service.select(entity) + #expect(!service.selection.isEmpty) + + service.selectionModes = [.edge] + + #expect(service.selection.isEmpty) + } + + /// The order guarantee that replaced insertion order when the store became a `Set`. + @MainActor + @Test("selection is ordered by body id, then kind, then ordinal") + func selectionIsDeterministicallyOrdered() { + guard let boxA = Shape.box(width: 10, height: 8, depth: 6), + let boxB = Shape.box(width: 4, height: 4, depth: 4) + else { + Issue.record("Shape.box returned nil") + return + } + let service = CADViewportService() + service.selectionModes = [.face, .edge, .vertex] + service.load(boxA, id: "aaa") + service.load(boxB, id: "zzz") + + guard let faceA = service.resolveFacePick(bodyID: "aaa", triangleIndex: 0), + let edgeA = service.resolveEdgePick(bodyID: "aaa", segmentIndex: 0), + let faceZ = service.resolveFacePick(bodyID: "zzz", triangleIndex: 0) + else { + Issue.record("resolve*Pick returned nil") + return + } + + // Selected in an order that disagrees with the expected one on every axis. + service.select(.face(faceZ)) + service.select(.edge(edgeA), scheme: .add) + service.select(.face(faceA), scheme: .add) + + #expect( + service.selection == [.face(faceA), .edge(edgeA), .face(faceZ)], + "body id first (aaa before zzz), then kind (face before edge)") + } + + /// A pick on a body the interactive context displays itself belongs to that context. + /// + /// The service must not treat "I could not resolve that" as "deselect", or one shared + /// selection would mean an AIS-displayed object could never stay selected. + @MainActor + @Test("A pick on an AIS-displayed body does not clear the shared selection") + func pickOnAnAISBodyDoesNotClearTheSelection() { + guard let box = Shape.box(width: 10, height: 8, depth: 6), + let stock = Shape.box(width: 20, height: 20, depth: 20) + else { + Issue.record("Shape.box returned nil") + return + } + let service = CADViewportService() + service.load(box, id: "part") + let stockObject = service.interactiveContext.display(stock) + // The one body in the rendered array that the context displays as its own object; the + // model body belongs to the service. + guard + let stockBodyID = service.interactiveContext.bodies.map(\.id).first(where: { + service.interactiveContext.displaysBody(withID: $0) + }) + else { + Issue.record("the displayed object has no body in the viewport") + return + } + service.interactiveContext.select(.body(stockObject)) + #expect(service.interactiveContext.selection.count == 1) + + // rawValue 0 decodes as object index 0, primitive 0, kind `.face`. + guard let stockPick = _PickResult(rawValue: 0, indexMap: [0: stockBodyID]) else { + Issue.record("failed to synthesise a pick result") + return + } + service.handlePick(stockPick) + + #expect( + service.interactiveContext.selection.count == 1, + "the service must leave a pick it does not own alone") + + // An empty-space pick still deselects, which is this service's contract, and now + // applies to the whole shared selection. + service.handlePick(nil) + #expect(service.interactiveContext.selection.isEmpty) + } + + /// `PickedFaceInfo` and its siblings survive as presentation types built from + /// `SubShapeRef`, so `shape`/`uid`/`faceIndex` have to keep agreeing with the ref they now + /// forward to, and the deprecated memberwise initialiser has to keep building one. + @MainActor + @Test("The picked-info types forward identity to their SubShapeRef") + func pickedInfoForwardsToItsRef() { + guard let (service, entity) = loadedService() else { + Issue.record("fixture setup failed") + return + } + guard case .face(let info) = entity else { + Issue.record("expected a face") + return + } + #expect(info.faceIndex == info.ref.ordinal) + #expect(info.uid == info.ref.uid) + #expect(entity.ref == info.ref) + + let rebuilt = PickedFaceInfo( + shape: info.shape, + uid: info.uid, + faceIndex: info.faceIndex, + bodyID: info.bodyID, + isHorizontal: info.isHorizontal, + isVertical: info.isVertical, + bounds: info.bounds, + zLevel: info.zLevel, + area: info.area, + description: info.description + ) + #expect(rebuilt == info, "the source-compatible initialiser mints an equivalent ref") + _ = service + } + + /// Two picks with the same ordinal on different bodies and no durable uid must stay + /// distinct. `SubShapeRef.==` alone cannot tell them apart (it falls back to the ordinal), + /// which is why `isSamePick` qualifies it with the body. + @Test("Same ordinal on different bodies is not the same pick") + func sameOrdinalOnDifferentBodiesIsNotTheSamePick() { + guard let box = Shape.box(width: 4, height: 4, depth: 4), + let face = box.subShape(type: .face, index: 0) + else { + Issue.record("Shape.box returned nil") + return + } + let ref = SubShapeRef(shape: face, uid: nil, ordinal: 0) + let bounds = FaceBounds(minX: 0, maxX: 4, minY: 0, maxY: 4) + func info(_ bodyID: String) -> PickedFaceInfo { + PickedFaceInfo( + ref: ref, bodyID: bodyID, isHorizontal: true, isVertical: false, + bounds: bounds, zLevel: 0, area: 16, description: "test face") + } + #expect(info("a") != info("b")) + #expect(info("a") == info("a")) + } + + /// The deprecated `SelectionSummary` spelling still resolves, so a consumer is warned + /// rather than broken. + /// + /// Renamed because `OCCTSwiftUXKit` has an unrelated public type of the same name; see the + /// bakeoff on OCCTSwiftInteraction#3. + @MainActor + @Test("SelectionSummary still resolves as the old name for SelectionMeasurements") + func deprecatedSelectionSummaryAliasResolves() { + guard let (service, entity) = loadedService() else { + Issue.record("fixture setup failed") + return + } + service.select(entity) + // Deliberately spelled with the deprecated name: the point of the test is that it + // still names the same type, so the warning here is the expected outcome. + guard let measurements: SelectionSummary = service.selectionMeasurements else { + Issue.record("expected measurements for a one-face selection") + return + } + #expect(measurements.faceCount == 1) + #expect(measurements == service.selectionMeasurements) + } +} diff --git a/Tests/OCCTSwiftCADKitTests/SmokeTests.swift b/Tests/OCCTSwiftCADKitTests/SmokeTests.swift index 7721dd5..904d6ab 100644 --- a/Tests/OCCTSwiftCADKitTests/SmokeTests.swift +++ b/Tests/OCCTSwiftCADKitTests/SmokeTests.swift @@ -868,11 +868,11 @@ struct SmokeTests { #expect(service.selected == nil, "more than one entity is selected") } - /// Regression for #29: `selectionSummary` reports sensible aggregates: count by kind, + /// Regression for #29: `selectionMeasurements` reports sensible aggregates: count by kind, /// total face area, total edge length, and combined bounds. @MainActor - @Test("selectionSummary reports sensible aggregates") - func selectionSummaryReportsAggregates() { + @Test("selectionMeasurements reports sensible aggregates") + func selectionMeasurementsReportsAggregates() { guard let box = Shape.box(width: 10, height: 8, depth: 6) else { Issue.record("Shape.box returned nil") return @@ -881,7 +881,7 @@ struct SmokeTests { service.selectionModes = [.face, .edge, .vertex] service.load(box, id: "box") - #expect(service.selectionSummary == nil, "empty selection has no summary") + #expect(service.selectionMeasurements == nil, "empty selection has no summary") guard let facePick = service.resolveFacePick(bodyID: "box", triangleIndex: 0) else { Issue.record("resolveFacePick returned nil") @@ -889,7 +889,7 @@ struct SmokeTests { } service.select(.face(facePick)) - guard let summary1 = service.selectionSummary else { + guard let summary1 = service.selectionMeasurements else { Issue.record("expected a summary for a non-empty selection") return } @@ -905,7 +905,7 @@ struct SmokeTests { } service.select(.edge(edgePick), scheme: .add) - guard let summary2 = service.selectionSummary else { + guard let summary2 = service.selectionMeasurements else { Issue.record("expected a summary after adding an edge") return } @@ -921,11 +921,11 @@ struct SmokeTests { } /// Regression for #29 review: a vertex-only selection must produce a zero-size (but - /// non-nil) bounds: the "point" case the bounds math of `selectionSummary` has to + /// non-nil) bounds: the "point" case the bounds math of `selectionMeasurements` has to /// handle alongside faces/edges, which contribute a real extent. @MainActor - @Test("selectionSummary reports a zero-size bounds for a vertex-only selection") - func selectionSummaryVertexOnlyBounds() { + @Test("selectionMeasurements reports a zero-size bounds for a vertex-only selection") + func selectionMeasurementsVertexOnlyBounds() { guard let box = Shape.box(width: 10, height: 8, depth: 6) else { Issue.record("Shape.box returned nil") return @@ -940,7 +940,7 @@ struct SmokeTests { } service.select(.vertex(vertexPick)) - guard let summary = service.selectionSummary, let bounds = summary.bounds else { + guard let summary = service.selectionMeasurements, let bounds = summary.bounds else { Issue.record("expected a summary with bounds for a vertex-only selection") return } diff --git a/docs/CHANGELOG-OCCTSwiftAIS.md b/docs/CHANGELOG-OCCTSwiftAIS.md index a49dd78..ba85d70 100644 --- a/docs/CHANGELOG-OCCTSwiftAIS.md +++ b/docs/CHANGELOG-OCCTSwiftAIS.md @@ -9,6 +9,37 @@ Most recent first. Breaking changes and deprecations documented here. ## Unreleased +### `InteractiveContext.selection` is now the package's only selection store + +Closes [OCCTSwiftInteraction#3](https://github.com/SecondMouseAU/OCCTSwiftInteraction/issues/3), +phase 3 of [ecosystem#43](https://github.com/SecondMouseAU/ecosystem/issues/43). + +`OCCTSwiftCADKit.CADViewportService` used to keep a selection alongside the `InteractiveContext` +it already owned. It now drives this one and projects it. Nothing here changes meaning for an +existing AIS-only consumer; the additions are: + +**New: `select(_:scheme:)`.** The four-scheme combination (`.replace` / `.add` / `.remove` / +`.xor`) that `CADViewportService.select(_:scheme:)` had and this context did not, applied to a +single sub-shape. The existing `select(_:)` and `deselect(_:)` are unchanged and now forward to +`.add` and `.remove`. The scheme parameter is deliberately **not** defaulted: a default of +`.replace` would silently retune every existing `select(x)` call site from add to replace. + +**New: `displaysBody(withID:)`.** Whether a body id names an object this context displays, as +opposed to one a host composited into `bodies` itself. A host sharing this selection needs it to +avoid clearing a selection it does not own when a pick it cannot resolve arrives. + +**Internal: `applySelection(_:scheme:)`.** The scheme rules, written once. `select(_:scheme:)` +passes a single-element set; area selection passes a whole match set, replacing the copy of the +same four-case switch that lived in `AreaSelection.swift`. + +**Behaviour worth knowing for a host.** A pick on an object displayed here, and a pick on a +`CADViewportService` model body, now write the same selection, so one replaces the other. That is +the consolidation, not a regression. + +**Tests:** 3 new here (`InteractiveContextSchemeTests`: the four schemes, `select(_:)` still +meaning add, and `displaysBody(withID:)`), 10 new in the CADKit target's `SharedSelectionTests`. +330 to 343 in 28 to 30 suites, all passing, none deleted or weakened. + ### Pick resolution moved to `OCCTSwiftTools` Closes [OCCTSwiftInteraction#2](https://github.com/SecondMouseAU/OCCTSwiftInteraction/issues/2), diff --git a/docs/CHANGELOG-OCCTSwiftCADKit.md b/docs/CHANGELOG-OCCTSwiftCADKit.md new file mode 100644 index 0000000..d83df2e --- /dev/null +++ b/docs/CHANGELOG-OCCTSwiftCADKit.md @@ -0,0 +1,91 @@ +--- +title: Changelog (CADKit) +nav_order: 6 +--- + +# Changelog + +Most recent first. Breaking changes and deprecations documented here. + +Started at OCCTSwiftInteraction#3, the first change to this target that a consumer has to read +before upgrading. Earlier history is in the pre-merge `OCCTSwiftCADKit` repository. + +## Unreleased + +### `CADViewportService` adopts the interactive context's selection + +Closes [OCCTSwiftInteraction#3](https://github.com/SecondMouseAU/OCCTSwiftInteraction/issues/3), +phase 3 of [ecosystem#43](https://github.com/SecondMouseAU/ecosystem/issues/43). + +This service held an `InteractiveContext` and ran a second selection alongside it. Its own source +described the situation: `.body` "exists on `SelectionMode` for +`OCCTSwiftAIS.InteractiveContext.selectionMode`, a separate, independent selection system this +service does not share state with". There is now one selection. + +#### What breaks + +**1. `SelectionSummary` is renamed to `SelectionMeasurements`**, and `selectionSummary` to +`selectionMeasurements`. Both old spellings still resolve as deprecated aliases, so this is a +warning rather than an error today. The reason is a name collision, not a merge: +`OCCTSwiftUXKit.SelectionSummary` is an unrelated public type (a selection pill's caption and SF +Symbol, built from `EntityRef` values) sharing no field, no input and no consumer with this one. +The bakeoff on the issue found nothing to merge, so the collision is resolved by naming, the same +way OCCTSwiftViewport's `SelectionFilter` was in phase 1. + +**2. `selection` is no longer in the order entries were selected.** It is ordered by (body id, +kind, ordinal). The underlying store is now a `Set`, so there is no insertion order left +to preserve; the ordering is deterministic, just not chronological. Code that assumed `selection` +grew by appending, or read `selection.last` as "the most recent pick", needs to change. + +**3. Assigning `selectionModes` clears the selection.** It is now `interactiveContext.selectionMode` +itself, and that property's documented behaviour is to clear the selection when the mode set +changes. Previously `selectionModes` was plain storage. Set the modes before selecting, not after. + +**4. `selectionModes` and `interactiveContext.selectionMode` are one setting.** Writing either +writes the other. The service initialises it to `[.face]` at `init`, which **overrides the +interactive context's own `[.body]` default**. An app that displays extra geometry into the +context (`interactiveContext.display(_:style:)`) and relied on picks against it producing a +whole-body selection now gets a face selection instead: set `service.selectionModes = [.body]`, or +`[.face, .body]`, to choose deliberately. + +**5. The two selections are no longer independent.** A pick on a model body replaces the whole +shared selection, including anything selected for an object displayed into the interactive +context, and a pick on empty space clears all of it. A pick on a body the context displays itself +is left for the context to resolve, rather than being treated as an unresolved pick and clearing +the selection. + +**6. `PickedFaceInfo`, `PickedEdgeInfo` and `PickedVertexInfo` now store an +`OCCTSwiftTools.SubShapeRef`** as `ref`, with `shape`, `uid` and `faceIndex` / `edgeIndex` / +`vertexIndex` forwarding to it. Every existing read compiles unchanged, and the previous +memberwise initialisers are kept as source-compatible conveniences, so this breaks nothing today. +It matters because it makes the types derived from the resolver's identity rather than parallel to +it: the three hand-written `==` implementations, each commented "mirrors +`OCCTSwiftAIS.SubShapeRef.==` exactly", are now one shared `isSamePick` rule. + +**7. `PickedFaceInfo.scalarValue` can now be `nil` where it was not.** For a `.perTriangle` scalar +field only, and only for a face that reached the selection without a pick (through the interactive +context directly, or by area selection): there is no picked triangle to sample. A `.perFace` field +is unaffected, and a real pick is unaffected. + +#### What does not break + +`selection`, `select(_:scheme:)`, `clearSelection()`, `selected`, `selectedFace`, `PickedEntity` +and the whole loading, overlay, clipping, comparison, scalar-field and escalation surface are +unchanged. `PickedEntity` gains no case: whole-body selection is a `SubShape.body` in the +interactive context, not a fourth `PickedEntity`. + +#### For the two known consumers + +- **PadCAM** reads `viewportService.selectedFace` (three sites) and calls `clearSelection()` (one + site), and reads only `bounds`, `zLevel` and `description` off it. All four keep working + unchanged. Its one exposure is item 4: it displays a stock box via + `interactiveContext.display(_:style:)` and installs a `ManipulatorWidget` on it, so picks + against that stock body now resolve under `[.face]` rather than `[.body]`. +- **OCCTSwiftUX** does not depend on this target at all, in either direction. Its own + `SelectionSummary` is untouched and stays where it is. + +#### Tests + +10 new in `SharedSelectionTests`, covering the shared mode set, both directions of the shared +selection, on-demand enrichment, ordering, the AIS-body pick case, ref forwarding, and cross-body +identity. Package total 330 to 343 in 28 to 30 suites, all passing, none deleted or weakened. diff --git a/docs/guides/getting-started-OCCTSwiftCADKit.md b/docs/guides/getting-started-OCCTSwiftCADKit.md index 4316523..ec0b532 100644 --- a/docs/guides/getting-started-OCCTSwiftCADKit.md +++ b/docs/guides/getting-started-OCCTSwiftCADKit.md @@ -62,7 +62,7 @@ display-mode / standard-view controls (bottom-trailing) built in. single-shape convenience: each **replaces** every other model body. For loading several parts or an assembly side by side, see [§4](#4-multi-body-and-assembly-loading-optional). -Call `loadFile(from:)` with a file URL — the extension picks the format +Call `loadFile(from:)` with a file URL, the extension picks the format (`.step`/`.stp`, `.stl`, `.brep`). The camera auto-focuses on the loaded shape. Run it from `.task` or a button action. @@ -97,7 +97,7 @@ struct CADScreen: View { ### Importing from a file picker (iOS) When you only have `Data` (from `.fileImporter` or drag-and-drop), use -`loadFromData(_:filename:)` — the filename's extension drives format detection. +`loadFromData(_:filename:)`: the filename's extension drives format detection. ```swift .fileImporter(isPresented: $showImporter, allowedContentTypes: [.data]) { result in @@ -121,8 +121,8 @@ viewport.loadShape(box, id: "model") ## 4. Multi-body and assembly loading (optional) -`load(_:id:transform:)` and `loadFile(from:id:progress:)` display several parts — or -several of an assembly's occurrences — as distinct, addressable **entities**, coexisting +`load(_:id:transform:)` and `loadFile(from:id:progress:)` display several parts, or +several of an assembly's occurrences, as distinct, addressable **entities**, coexisting rather than replacing one another. Unlike the deprecated single-shape overloads, camera focus is *not* automatic; call `focus(on:)` once you've loaded what should be visible. @@ -132,7 +132,7 @@ viewport.load(coverShape, id: "cover", transform: [ 1, 0, 0, 0, 1, 0, 0, 0, 1, - 0, 0, 45, // 45mm along Z — a rigid 12-element affine matrix: + 0, 0, 45, // 45mm along Z, a rigid 12-element affine matrix: ]) // 9 rotation elements (row-major 3x3), then translation. viewport.focus(on: ["housing", "cover"]) ``` @@ -165,7 +165,7 @@ for hit in viewport.selection { } ``` -The multi-entity API and the deprecated single-shape overloads are safe to mix — they +The multi-entity API and the deprecated single-shape overloads are safe to mix, they share one internal entity registry, so `loadShape(_:id:)`/`loadFile(from:progress:)` (which still replace every model body) register what they load there too, and `loadedShapes`/`visibility`/`removeAll()`/`entityID(forBodyID:)` all see it. `loadedShape` @@ -174,26 +174,32 @@ was loaded. ### Memory behavior -Each occurrence loaded via `load(_:id:transform:)` is tessellated independently — v1 does +Each occurrence loaded via `load(_:id:transform:)` is tessellated independently, v1 does not deduplicate geometry across repeated instances of the same underlying part (an assembly's "product and occurrence" model, where placement lives on the occurrence and definitions are shared, is not implemented). Measured on this machine: loading 1245 -occurrences of a plain 10×8×6mm box (a synthetic proxy — not the actual reference +occurrences of a plain 10×8×6mm box (a synthetic proxy, not the actual reference corpus's own geometry, which is more complex) cost **~646 MB** resident memory, about 0.52 MB/occurrence. Real parts with more complex geometry (fillets, holes, threads) will cost more per occurrence than this proxy. For an assembly with many repeated instances of a small number of unique products, sharing tessellated geometry across occurrences of the -same product would very likely reduce memory substantially — worth a follow-up if +same product would very likely reduce memory substantially, worth a follow-up if per-occurrence memory becomes a real constraint at your assembly's scale. ## 5. Handle a pick Face picking is enabled by default. When the user taps a face, the service updates its -`selection` property (`[PickedEntity]`) — a real viewport pick always replaces the whole +`selection` property (`[PickedEntity]`), a real viewport pick always replaces the whole selection with the one entity hit, so `selection` is `[.face(PickedFaceInfo)]` for a single face pick. Because `CADViewportService` is `@Observable`, read `selection` directly in your view to react. +`selection` is a projection of `viewport.interactiveContext.selection`, not a second +selection: since OCCTSwiftInteraction#3 this service drives the interactive context's +selection rather than running one alongside it. A pick on empty space clears that shared +selection; a pick on a body the interactive context displays itself is left for the +context to resolve. + ```swift struct CADScreen: View { @State private var viewport = CADViewportService() @@ -221,8 +227,8 @@ struct CADScreen: View { ``` The picked entity is highlighted in the viewport automatically. To do further geometric -analysis, construct a `Face` from `face.shape` — the durable identity captured at pick -time — rather than re-deriving it from `face.faceIndex`: +analysis, construct a `Face` from `face.shape`: the durable identity captured at pick +time, rather than re-deriving it from `face.faceIndex`: ```swift if case .face(let face)? = viewport.selection.first, let occtFace = Face(face.shape) { @@ -230,19 +236,19 @@ if case .face(let face)? = viewport.selection.first, let occtFace = Face(face.sh } ``` -`face.faceIndex` is the ephemeral render-path ordinal the pick came from — valid only +`face.faceIndex` is the ephemeral render-path ordinal the pick came from, valid only against that body's own tessellation. Don't use it to subscript `loadedShape.faces()[face.faceIndex]`: once a face is shared between two shells, that enumeration counts the shared face once per shell, so the same ordinal can name a different face than the one actually picked. `face.uid` additionally carries a durable -`BRepGraph.GraphUID` when a graph was available at pick time — the handle that survives +`BRepGraph.GraphUID` when a graph was available at pick time, the handle that survives a later mutation an ordinal alone does not. `selectedFace: PickedFaceInfo?` and `selected: PickedEntity?` still work as deprecated -conveniences — each non-nil only when the selection is exactly one entity (and, for -`selectedFace`, only when that entity is a face) — for callers not yet migrated to +conveniences, each non-nil only when the selection is exactly one entity (and, for +`selectedFace`, only when that entity is a face), for callers not yet migrated to `selection`. ## 6. Edge and vertex picking (optional) @@ -273,13 +279,20 @@ for entity in viewport.selection { A body whose `ViewportBody` has no `edgeIndices`/`vertices` populated (not edge/vertex pickable, e.g. a loose mesh with no recovered solid) simply never produces an edge/vertex -pick on that body — face picking on the same body is unaffected. `selectionModes` also +pick on that body, face picking on the same body is unaffected. `selectionModes` also gates face picking itself; remove `.face` to disable it. -`SelectionMode` is `OCCTSwiftAIS.SelectionMode` — the same type -`InteractiveContext.selectionMode` uses — but `CADViewportService.selectionModes` is an -entirely separate, independent selection system: the two don't share state, and -`SelectionMode.body` has no effect here (there's no whole-body `PickedEntity` case). +`selectionModes` **is** `interactiveContext.selectionMode`, not a copy of it: it is the +same `OCCTSwiftAIS.SelectionMode` set, and reading or writing either reads or writes the +other. `CADViewportService` initialises it to `[.face]`, overriding the interactive +context's own `[.body]` default. Assigning a different set clears the selection, which is +the interactive context's documented behaviour for `selectionMode`. + +`SelectionMode.body` selects a `SubShape.body` in the interactive context, for objects +displayed there directly via `interactiveContext.display(_:style:)`. It produces no +`PickedEntity`, because there is no whole-body case: read +`viewport.interactiveContext.selection` for those. `viewport.selection` is the sub-shape +projection. ## 7. Multi-selection (optional) @@ -287,24 +300,30 @@ A real viewport pick always replaces the whole `selection` (see [§5](#5-handle- Build a multi-selection programmatically with `select(_:scheme:)`: ```swift -viewport.select(faceA) // .replace (default) — selection = [faceA] +viewport.select(faceA) // .replace (default), selection = [faceA] viewport.select(faceB, scheme: .add) // selection = [faceA, faceB] viewport.select(faceA, scheme: .remove) // selection = [faceB] -viewport.select(faceB, scheme: .xor) // toggles faceB off — selection = [] +viewport.select(faceB, scheme: .xor) // toggles faceB off, selection = [] ``` -`SelectionScheme` is `OCCTSwiftAIS.SelectionScheme` (`.replace`/`.add`/`.remove`/`.xor`) — +`SelectionScheme` is `OCCTSwiftAIS.SelectionScheme` (`.replace`/`.add`/`.remove`/`.xor`), the same combination semantics `selectRectangle`/`selectPolygon` area selection uses on -the AIS side. Membership follows `PickedEntity`'s own durable-identity `Equatable` (`uid` -when both sides have one), so the same face/edge/vertex is recognized as already-selected -even if it was picked at a different ephemeral ordinal. +the AIS side. `select(_:scheme:)` delegates to `interactiveContext.select(_:scheme:)`, +which holds the selection. Membership follows `SubShapeRef`'s own durable-identity rule +(`uid` when both sides have one, else the ordinal plus the body), so the same +face/edge/vertex is recognized as already-selected even if it was picked at a different +ephemeral ordinal. + +`viewport.selection` is ordered by (body id, kind, ordinal) rather than by when each entry +was selected: the underlying store is a `Set`, so there is no insertion order +left to preserve. -Every selected entity is highlighted — a translucent yellow triangle patch aggregating +Every selected entity is highlighted, a translucent yellow triangle patch aggregating every selected face, a bright cyan polyline aggregating every selected edge's segments, a bright magenta point sprite per selected vertex. ```swift -if let summary = viewport.selectionSummary { +if let summary = viewport.selectionMeasurements { print(summary.faceCount, summary.edgeCount, summary.vertexCount) print(summary.totalArea, summary.totalLength) // sums over selected faces/edges print(summary.bounds) // combined ShapeBounds, or nil @@ -320,8 +339,8 @@ single-selection conveniences. ## 8. Overlays (optional) -Anything that isn't part of the imported model — stock boxes, toolpaths, flat-pattern -outlines, annotations — goes through named overlay layers. They composite with the model +Anything that isn't part of the imported model, stock boxes, toolpaths, flat-pattern +outlines, annotations, goes through named overlay layers. They composite with the model and selection on every rebuild, in ascending `id` order. ```swift @@ -333,8 +352,8 @@ viewport.clearAllOverlays() ## 9. Scalar field display (optional) -Paint a scalar value over a loaded body — deviation, curvature, wall thickness, confidence, -anything indexed by face or triangle — with `setScalarField(_:forBody:)`: +Paint a scalar value over a loaded body, deviation, curvature, wall thickness, confidence, +anything indexed by face or triangle, with `setScalarField(_:forBody:)`: ```swift let deviationField = ScalarField( @@ -348,12 +367,12 @@ let deviationField = ScalarField( viewport.setScalarField(deviationField, forBody: "candidate") ``` -Updating or clearing a field (`setScalarField(nil, forBody:)`) rebuilds that body — its +Updating or clearing a field (`setScalarField(nil, forBody:)`) rebuilds that body, its geometry is unchanged, but currently this does re-upload the whole body, not just the style buffer. That's a deliberate workaround, not the design: `OCCTSwiftViewport`'s own `ViewportBody.triangleStyles` is documented to support a cheap in-place mutation instead, but this was empirically confirmed (rendering before/after and comparing pixels) to -silently not update an already-rendered body against its currently-pinned floor — the +silently not update an already-rendered body against its currently-pinned floor, the renderer only rebuilds GPU buffers when a body's `generation` changes, and an in-place mutation never changes it. Once that's fixed upstream, this can switch back to the cheap path with no change to `setScalarField`'s own signature. @@ -362,12 +381,12 @@ path with no change to `setScalarField`'s own signature. ```swift .viridis, .magma, .turbo // sequential ramps for an unsigned magnitude -.diverging(center: 0) // two-sided ramp — use for signed deviation +.diverging(center: 0) // two-sided ramp, use for signed deviation .threshold(levels: [0.5, 1.0]) // discrete pass/warn/fail bands .custom([(0.0, blue), (2.0, red)]) // explicit (value, color) stops, linearly interpolated ``` -A face pick reports its scalar value directly — no separate lookup needed: +A face pick reports its scalar value directly, no separate lookup needed: ```swift if case .face(let face)? = viewport.selection.first, viewport.selection.count == 1 { @@ -377,7 +396,7 @@ if case .face(let face)? = viewport.selection.first, viewport.selection.count == } ``` -The legend is part of the feature, not a nicety — read it to render a color bar with real +The legend is part of the feature, not a nicety, read it to render a color bar with real tick labels rather than a decorative gradient: ```swift @@ -388,13 +407,13 @@ if let legend = viewport.scalarFieldLegend { ``` `scalarFieldLegend` reports the most recently set field (across whichever body it's on), -falling back to another still-active field on a different body if that one is cleared — +falling back to another still-active field on a different body if that one is cleared, only going `nil` once no body has an active field at all; `scalarField(forBody:)` reads any particular body's field directly. **Performance:** rebuilding a body's triangle styles scales linearly with triangle count. Measured on this machine: a single body with 25,132 triangles took ~4.3ms per -`setScalarField` call (~0.17µs/triangle) — a synthetic proxy (a finely-tessellated +`setScalarField` call (~0.17µs/triangle), a synthetic proxy (a finely-tessellated cylinder), not the actual reference corpus's own geometry, but representative of the cost shape for a body at that scale. @@ -424,7 +443,7 @@ Four modes: .wipe(axis: .x, position: 0) // spatially split: reference below `position`, candidate above ``` -`.deviation` doesn't compute anything itself — CADKit stays free of measurement +`.deviation` doesn't compute anything itself, CADKit stays free of measurement responsibility, matching the same trade-off `setScalarField` makes elsewhere. Compute the per-face/per-triangle distance from candidate to reference yourself and set it first: @@ -433,26 +452,26 @@ viewport.setScalarField(deviationField, forBody: "candidate") viewport.setComparison(ComparisonView(referenceID: "reference", candidateID: "candidate", mode: .deviation)) ``` -Calling `setComparison` again — with a different mode, or updated `position`/ -`referenceOpacity` for the same mode, e.g. while the user drags a wipe slider — first +Calling `setComparison` again, with a different mode, or updated `position`/ +`referenceOpacity` for the same mode, e.g. while the user drags a wipe slider, first undoes whatever the previous comparison did before applying the new one, so calls don't compound. `setComparison(nil)` restores both entities to their plain display without reloading either one. `.wipe` is a plain CPU-side triangle filter (each body keeps only the triangles on its -side of the plane), not `ViewportController.clipPlanes` — that mechanism clips the whole +side of the plane), not `ViewportController.clipPlanes`: that mechanism clips the whole scene uniformly, so it can't show the reference and candidate differently. A side effect: a wiped body's wireframe edges and vertex-picking aren't preserved, only its shaded -triangles — the split is a review affordance, not a full re-tessellation. +triangles, the split is a review affordance, not a full re-tessellation. -`.overlay`/`.sideBySide` mutate a body's color/transform in place — cheap, since the +`.overlay`/`.sideBySide` mutate a body's color/transform in place, cheap, since the renderer reads both fresh every frame rather than caching them (unlike the scalar-field style buffer above). `.wipe` rebuilds each side's body (a fresh `generation`, like `setScalarField` does), scaled to that body's triangle count. ## 11. Clipping and section planes (optional) -Cut away geometry to see inside a part — internal bores, pockets, ribs — or inspect exact +Cut away geometry to see inside a part, internal bores, pockets, ribs, or inspect exact cross-sections along a prismatic axis: ```swift @@ -460,13 +479,13 @@ let planeID = viewport.addClippingPlane(origin: .zero, normal: SIMD3(0, 0, 1)) ``` Geometry on the side the normal points away from is hidden. By default -(`showCapSurface: true`) the cut also shows solid material rather than looking hollow — this +(`showCapSurface: true`) the cut also shows solid material rather than looking hollow, this is a genuine B-Rep split and retessellation of the affected body(ies), not a shader trick (`OCCTSwiftViewport` has no shader-level capping), so it costs real per-body geometry work on every clipping-plane change, unlike the instant, GPU-only hollow clip underneath it. For the common "step a plane along a prismatic axis" case, `sectionSweep` reuses a single -dedicated plane instead of accumulating one per call — safe to call every frame of a drag: +dedicated plane instead of accumulating one per call, safe to call every frame of a drag: ```swift viewport.sectionSweep(axis: SIMD3(0, 0, 1), position: sliderValue) @@ -479,7 +498,7 @@ drag (cheap, instant hollow clip) and only turn it back on once the drag settles viewport.clippingPlanes[0].showCapSurface = isDragging ? false : true ``` -Multiple planes compose — both the hollow clip and, for cap-enabled planes, the cut itself +Multiple planes compose, both the hollow clip and, for cap-enabled planes, the cut itself (a sequential chain of splits, so the visible remainder is their intersection): ```swift @@ -487,7 +506,7 @@ viewport.addClippingPlane(origin: .zero, normal: SIMD3(1, 0, 0), showCapSurface: viewport.addClippingPlane(origin: .zero, normal: SIMD3(0, 1, 0), showCapSurface: false) ``` -Picking respects active clipping planes — a face/edge/vertex pick's own position is tested +Picking respects active clipping planes, a face/edge/vertex pick's own position is tested against every enabled plane before it resolves, even though `OCCTSwiftViewport`'s own GPU pick pass doesn't do this itself, so clipped-away geometry never steals a pick. @@ -496,15 +515,15 @@ viewport.removeClippingPlane(id: planeID) // clears just that one; clippingPla ``` **Reload behavior:** capping DOES automatically re-apply when you reload an entity (even -under an id already in use) — every loader ends by syncing clipping state, so the new +under an id already in use), every loader ends by syncing clipping state, so the new geometry is clipped/capped immediately, with no further clipping-plane call needed. A `ScalarField` set via `setScalarField(_:forBody:)` is different: it does NOT survive a -reload (or a genuine re-cut from capping) — its ordinals are tied to the specific +reload (or a genuine re-cut from capping), its ordinals are tied to the specific tessellation they were computed against, so the caller re-applies it after. ## 12. Escalation: asking a bounded question about geometry (optional) -The runtime half of a human-in-the-loop model — an agent (an MCP-connected reconstruction +The runtime half of a human-in-the-loop model, an agent (an MCP-connected reconstruction pipeline, say) asks a question grounded in specific geometry and awaits an answer, without needing to own the UI itself: @@ -544,22 +563,22 @@ Two things make this more than a dialog box: - **The request's entities are highlighted**, so the question is grounded in visible geometry rather than a floating description. -- **The human can answer by picking geometry instead of choosing a candidate** — +- **The human can answer by picking geometry instead of choosing a candidate**, `respondWithCurrentSelection()` wraps whatever `selection` currently holds in `.picked(...)`, for "none of those, this one." `EscalationCardView` is one adaptive layout (capped to a comfortable phone-width column) -rather than separate macOS/iOS view types — usable as a floating panel on a larger surface +rather than separate macOS/iOS view types, usable as a floating panel on a larger surface too. If a previous escalation is still pending when you call `present(_:)` again, it's resolved `.deferred` first, so calling it repeatedly is always safe. Removing (or reloading) -an entity a pending escalation references — or a full `removeAll()` — auto-resolves it +an entity a pending escalation references, or a full `removeAll()`: auto-resolves it `.rejected` rather than leaving `present(_:)` suspended over geometry that's gone. ## Next steps - See [`docs/reference/CADViewportService.md`](../reference/CADViewportService.md) for the full public API: every method signature, the `ShapeBounds` / `PickedEntity` / - `PickedFaceInfo` / `PickedEdgeInfo` / `PickedVertexInfo` / `SelectionSummary` / + `PickedFaceInfo` / `PickedEdgeInfo` / `PickedVertexInfo` / `SelectionMeasurements` / `ScalarField` / `ColorMap` / `ScalarFieldLegend` / `ComparisonView` / `ComparisonMode` / `Axis` / `ClippingPlane` / `EscalationRequest` / `EscalationCandidate` / `EscalationResponse` / `FaceBounds` types, and `CADViewportError`. diff --git a/docs/index.md b/docs/index.md index 697c458..7b54345 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,6 +35,8 @@ These were three separate release lines before the merge, kept separate rather t - [OCCTSwiftTools](CHANGELOG-OCCTSwiftTools.md) - [OCCTSwiftAIS](CHANGELOG-OCCTSwiftAIS.md) +- [OCCTSwiftCADKit](CHANGELOG-OCCTSwiftCADKit.md), started at the first change a consumer of that + target has to read before upgrading ## Pre-merge documentation indexes diff --git a/docs/module-notes/OCCTSwiftCADKit.md b/docs/module-notes/OCCTSwiftCADKit.md index 5ed4cbd..ed5ad0f 100644 --- a/docs/module-notes/OCCTSwiftCADKit.md +++ b/docs/module-notes/OCCTSwiftCADKit.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -Swift Package extracted from PadCAM's CAD viewport plumbing. It owns: a Metal 3D viewport, file import (STEP/STL/BREP via `OCCTSwiftTools.CADFileLoader`) as either a single shape or several coexisting, addressable entities (an assembly's parts/occurrences), face/edge/vertex picking with single- or multi-selection, scalar field display (deviation heatmaps and similar, per face or per triangle), mesh/solid comparison display (ghosting, deviation heatmap, side-by-side, spatial wipe — for reconstruction review, via `setComparison(_:)`), clipping and section planes (hollow or solid-capped, via `clippingPlanes`/`addClippingPlane`/`sectionSweep`), human-in-the-loop escalation (asking a bounded question grounded in specific geometry and awaiting an answer — by candidate choice or by picking — via `present(_:)`), and a generic overlay-layers API for caller-supplied bodies (stock boxes, toolpaths, flat patterns, etc.). Designed to be shared by PadCAM and a forthcoming UnfoldEngine test app. +Swift Package extracted from PadCAM's CAD viewport plumbing. It owns: a Metal 3D viewport, file import (STEP/STL/BREP via `OCCTSwiftTools.CADFileLoader`) as either a single shape or several coexisting, addressable entities (an assembly's parts/occurrences), face/edge/vertex picking with single- or multi-selection, scalar field display (deviation heatmaps and similar, per face or per triangle), mesh/solid comparison display (ghosting, deviation heatmap, side-by-side, spatial wipe, for reconstruction review, via `setComparison(_:)`), clipping and section planes (hollow or solid-capped, via `clippingPlanes`/`addClippingPlane`/`sectionSweep`), human-in-the-loop escalation (asking a bounded question grounded in specific geometry and awaiting an answer, by candidate choice or by picking, via `present(_:)`), and a generic overlay-layers API for caller-supplied bodies (stock boxes, toolpaths, flat patterns, etc.). Designed to be shared by PadCAM and a forthcoming UnfoldEngine test app. Tiny by design: 11 files in `Sources/OCCTSwiftCADKit/`. If a piece of code starts to know about CAM, sheet metal, or any specific application domain, it doesn't belong here. @@ -22,68 +22,68 @@ swift test --filter SmokeTests swift test --filter SmokeTests.pickedFaceInfoEquality ``` -Targets: macOS 15+ / iOS 18+ (set by `OCCTSwiftViewport`'s Metal requirements, not by anything here). Both targets are pinned to `.swiftLanguageMode(.v6)` in `Package.swift` — that's why `CADViewportService` is `@MainActor`-isolated and the public value types are `Sendable`. Don't relax this when adding new targets. +Targets: macOS 15+ / iOS 18+ (set by `OCCTSwiftViewport`'s Metal requirements, not by anything here). Both targets are pinned to `.swiftLanguageMode(.v6)` in `Package.swift`: that's why `CADViewportService` is `@MainActor`-isolated and the public value types are `Sendable`. Don't relax this when adding new targets. ## Dependency wiring (important) `Package.swift` consumes four sibling repos as **URL-based SPM dependencies**: -- `OCCTSwift` (geometry kernel; ships a binary `OCCT.xcframework` via its own release artefact — fetched transparently) +- `OCCTSwift` (geometry kernel; ships a binary `OCCT.xcframework` via its own release artefact, fetched transparently) - `OCCTSwiftViewport` (Metal renderer) - `OCCTSwiftTools` (`CADFileLoader`, `CADBodyMetadata`, `BodyUtilities`, `CADFileFormat`, `FaceIdentityTable`/`EdgeIdentityTable`/`VertexIdentityTable`) -- `OCCTSwiftAIS` (`InteractiveContext`, `ManipulatorWidget`, `Dimension`, sub-shape selection — exposed via `service.interactiveContext`; `SelectionMode` also backs `CADViewportService.selectionModes`, and `SelectionScheme` backs `select(_:scheme:)`) +- `OCCTSwiftAIS` (`InteractiveContext`, `ManipulatorWidget`, `Dimension`, sub-shape selection, exposed via `service.interactiveContext`; `SelectionMode` also backs `CADViewportService.selectionModes`, and `SelectionScheme` backs `select(_:scheme:)`) -`OCCTSwiftTools` used to live as a target inside `OCCTSwiftViewport`. It was split into its own repo (https://github.com/SecondMouseAU/OCCTSwiftTools) as of `OCCTSwiftViewport 0.51.0` and must now be sourced from its own package — don't try to pull the `OCCTSwiftTools` product from the Viewport package, it's no longer there. +`OCCTSwiftTools` used to live as a target inside `OCCTSwiftViewport`. It was split into its own repo (https://github.com/SecondMouseAU/OCCTSwiftTools) as of `OCCTSwiftViewport 0.51.0` and must now be sourced from its own package, don't try to pull the `OCCTSwiftTools` product from the Viewport package, it's no longer there. -URL-based deps are the preferred form. If you hit a version-resolution problem you need to debug locally, you can temporarily point `Package.swift` at sibling working trees (`.package(path: "../OCCTSwiftTools")` etc.) — but commits should always land with URL-based deps. +URL-based deps are the preferred form. If you hit a version-resolution problem you need to debug locally, you can temporarily point `Package.swift` at sibling working trees (`.package(path: "../OCCTSwiftTools")` etc.), but commits should always land with URL-based deps. ## API design rules - **No domain leaks.** This library knows about loading shapes, displaying them, and picking faces. It does not know about toolpaths, stock, sheet metal, bend allowance, machining origins, or anything specific to one app. App-specific geometry rides as overlay layers (`setOverlay(id:bodies:)`). - **`PickedFaceInfo` must stay self-contained.** It originally referenced PadCAM's `DetectedSurface.SurfaceBounds`; that was replaced with a local `FaceBounds`. Don't reintroduce dependencies on caller types. -- **The viewport API mirrors the underscored `OCCTSwiftViewport` aliases.** `_ViewportController`, `_ViewportBody`, `_PickResult`, `_ViewportConfiguration`, `_PickingConfiguration`, `_MetalViewportView` — these are the public typealiases the upstream package exposes. Use them in public surfaces (so callers don't need to import the un-aliased namespace). +- **The viewport API mirrors the underscored `OCCTSwiftViewport` aliases.** `_ViewportController`, `_ViewportBody`, `_PickResult`, `_ViewportConfiguration`, `_PickingConfiguration`, `_MetalViewportView`: these are the public typealiases the upstream package exposes. Use them in public surfaces (so callers don't need to import the un-aliased namespace). ## Architecture in one paragraph -`CADViewportService` is `@MainActor @Observable`. It owns a `_ViewportController` and an `InteractiveContext` (shared viewport — `interactiveContext.viewport === controller`). Bodies live in `interactiveContext.bodies` (single source of truth, the array that `_MetalViewportView` actually renders); the service mirrors them into its own `bodies` for `@Observable` consumers via a Combine `$bodies` sink. The service composes four kinds of body into that single array: (1) **model bodies** — either the most recent `loadFile`/`loadShape`/`loadFromData` (single-shape, deprecated, replaces everything) or one or more coexisting **entities** loaded via `load`/`loadFile(from:id:)`/`loadFromData(_:filename:id:)`, all tracked in one private `entities: [String: Entity]` registry regardless of which API loaded them (each owning a subset of body ids — removable/visible/focusable independently; the deprecated methods register too, one entity per resulting body, which is what makes mixing the two styles safe rather than something to avoid), (2) **overlay layers** added by callers via `setOverlay`, sorted alphabetically by id, (3) up to three **selection highlight** bodies rebuilt from the whole `selection` array whenever it changes — a translucent triangle patch aggregating every selected face's triangles, a bright polyline aggregating every selected edge's segments, a point sprite per selected vertex — (4) **AIS-owned bodies** appended by `interactiveContext.display(_:)` / `appendInternalBody(_:)` (manipulator handles, dimensions). On rebuild, the service tracks the set of CADKit-owned ids and only replaces those — AIS-owned bodies are left untouched. The controller's `onPick` callback dispatches a `_PickResult` on its `kind` (face/edge/vertex, gated by `selectionModes`) to a per-kind resolver, which maps the render-path ordinal to durable identity (a `Shape` + optional `BRepGraph.GraphUID`) via the picked body's `FaceIdentityTable`/`EdgeIdentityTable`/`VertexIdentityTable` — one graph + table set per loaded body, retained for the body's lifetime — and calls `select(_:scheme: .replace)`, replacing the whole `selection: [PickedEntity]` (a real pick always replaces, matching `OCCTSwiftAIS`'s own point-pick behavior; `select(_:scheme:)` — mirroring `SelectionScheme`'s `.replace`/`.add`/`.remove`/`.xor` — is how a caller builds a multi-selection programmatically). Each entry's `bodyID` maps back to an owning entity via `entityID(forBodyID:)`. Removing an entity (`remove(id:)`/`removeAll()`) prunes only the `selection` entries referencing its bodies, leaving the rest selected. `InteractiveContext` separately observes `viewport.$pickResult` for AIS-side selection (a distinct, independent selection system CADKit does not share state with). Independently of all that, `setScalarField(_:forBody:)` paints a `ScalarField` (per-face or per-triangle `Double` values + a `ColorMap`) onto a body by rebuilding it — `modelBodies[i] = _ViewportBody(id: ..., ..., triangleStyles: newStyles, ...)`, copying every other field from the current body — which mints a fresh `generation`. This is a deliberate workaround, not the design intent: `_ViewportBody.triangleStyles`'s own doc comment says an in-place mutation (`modelBodies[i].triangleStyles = newStyles`, `generation` unchanged) should be enough, but that's empirically confirmed false against `OCCTSwiftViewport`'s pinned floor — its renderer only rebuilds a body's GPU buffers (including the style buffer) when `generation` changes, so an in-place mutation on an already-rendered body silently never reaches the screen. `PickedFaceInfo.scalarValue` is resolved from the picked body's field (if any) inside `resolveFacePick`, keyed by the same `faceIndex`/`triangleIndex` the field's own `Domain` (`.perFace`/`.perTriangle`) expects. `setComparison(_:)` layers a `ComparisonMode` on top of two already-loaded entities: `.overlay`/`.sideBySide` mutate `color.w`/`transform` in place (both read live per frame by the renderer, unlike `triangleStyles`, so no body reconstruction needed), `.wipe` rebuilds each side's bodies keeping only the triangles on its side of a world-space plane (`wipeFiltered`), and `.deviation` is a marker only — the caller drives it via `setScalarField(_:forBody:)` on the candidate. Each call to `setComparison` first undoes whatever the previous comparison did (from a `comparisonBackup` snapshot for the body-mutating modes, or by clearing the candidate's scalar field for `.deviation`) before applying the new one, so repeated calls (e.g. a live-dragged wipe `position`) never compound. Separately, `clippingPlanes`/`addClippingPlane`/`removeClippingPlane`/`sectionSweep` push a `[ClippingPlane]` (origin + normal, `id`-addressable) to `controller.clipPlanes` (`OCCTSwiftViewport`'s global, GPU-only, up-to-4-plane hollow clip) via `syncClippingPlanes`, then call `updateCapSurfaces()`, which — for whichever planes have `showCapSurface == true` — reconstructs each affected body from a genuine B-Rep split of its ORIGINAL shape (`cappedShape`, sequentially cutting at every cap-enabled plane) rather than relying on any shader-level cap (there isn't one). `updateCapSurfaces` considers the union of every body it's currently capping and every loaded body on each call, skipping a body entirely when its cap outcome hasn't actually changed since last time (via `boundsPracticallyEqual`, only for a body still visibly showing that outcome — otherwise it restores/recaps through `replaceBody`, re-tessellating from the pristine shape `clippingSourceShapes` remembers) rather than unconditionally restoring-then-recapping everything on every call, and preserves an independently active BODY-MUTATING comparison (`.overlay`/`.sideBySide`/`.wipe` — never `.deviation`, which has no body to preserve) around its own recompute — so it composes correctly with repeated calls, with newly-loaded geometry (every loader ends by calling it too, not just the dedicated clipping-plane mutators), and with `setComparison`'s own undo step. Separately again, `present(_:) async -> EscalationResponse` is the human-in-the-loop entry point: it highlights `EscalationRequest.entities` via the SAME `select(_:scheme:)` mechanism a real pick uses (`.replace` then `.add` for each), shows any `EscalationCandidate.previewBodyID` (searching `modelBodies` then every overlay), stores an `EscalationRequest` in `pendingEscalation`, and suspends on a `CheckedContinuation` until `respond(_:)` (or the `respondWithCurrentSelection()` convenience, which wraps whatever `selection` currently holds in `.picked(...)`) resumes it. If a PREVIOUS escalation is still pending when `present` is called again, it's resolved `.deferred` first — same "undo the previous one before applying the new one" shape as `setComparison`. `remove(id:)`/`resetAllModelState()` auto-resolve a pending escalation as `.rejected` if it referenced a body that's now gone, rather than leaving a suspended `present(_:)` call stuck forever. +`CADViewportService` is `@MainActor @Observable`. It owns a `_ViewportController` and an `InteractiveContext` (shared viewport, `interactiveContext.viewport === controller`). Bodies live in `interactiveContext.bodies` (single source of truth, the array that `_MetalViewportView` actually renders); the service mirrors them into its own `bodies` for `@Observable` consumers via a Combine `$bodies` sink. The service composes four kinds of body into that single array: (1) **model bodies**, either the most recent `loadFile`/`loadShape`/`loadFromData` (single-shape, deprecated, replaces everything) or one or more coexisting **entities** loaded via `load`/`loadFile(from:id:)`/`loadFromData(_:filename:id:)`, all tracked in one private `entities: [String: Entity]` registry regardless of which API loaded them (each owning a subset of body ids, removable/visible/focusable independently; the deprecated methods register too, one entity per resulting body, which is what makes mixing the two styles safe rather than something to avoid), (2) **overlay layers** added by callers via `setOverlay`, sorted alphabetically by id, (3) up to three **selection highlight** bodies rebuilt from the whole `selection` array whenever it changes, a translucent triangle patch aggregating every selected face's triangles, a bright polyline aggregating every selected edge's segments, a point sprite per selected vertex, (4) **AIS-owned bodies** appended by `interactiveContext.display(_:)` / `appendInternalBody(_:)` (manipulator handles, dimensions). On rebuild, the service tracks the set of CADKit-owned ids and only replaces those, AIS-owned bodies are left untouched. The controller's `onPick` callback dispatches a `_PickResult` on its `kind` (face/edge/vertex, gated by `selectionModes`) to a per-kind resolver, which maps the render-path ordinal to durable identity (a `Shape` + optional `BRepGraph.GraphUID`) via the picked body's `FaceIdentityTable`/`EdgeIdentityTable`/`VertexIdentityTable`: one graph + table set per loaded body, retained for the body's lifetime, and calls `select(_:scheme: .replace)` (a real pick always replaces, matching `OCCTSwiftAIS`'s own point-pick behavior; `select(_:scheme:)`, carrying `SelectionScheme`'s `.replace`/`.add`/`.remove`/`.xor`, is how a caller builds a multi-selection programmatically). **Since OCCTSwiftInteraction#3 that selection is `interactiveContext.selection`, not a second one**: `select`/`clearSelection` delegate to the context, `selection: [PickedEntity]` is the context's `Set` enriched and mirrored into stored state for `@Observable` consumers (ordered by body id, kind, ordinal, since a `Set` has no insertion order to preserve), and `selectionModes` IS `interactiveContext.selectionMode`. Each entry's `bodyID` maps back to an owning entity via `entityID(forBodyID:)`. Removing an entity (`remove(id:)`/`removeAll()`) prunes only the `selection` entries referencing its bodies, leaving the rest selected. `InteractiveContext` separately observes `viewport.$pickResult` and resolves picks against the objects IT displays (`display(_:style:)`), which are not CADKit's model bodies; both paths now write the same selection, so `handlePick` skips a pick whose body the context displays (`displaysBody(withID:)`) rather than treating it as unresolved and clearing a selection CADKit does not own. CADKit's bodies are deliberately NOT registered as context entries: the context's `updateSelectionVisuals` paints `triangleStyles`, which is the same array `setScalarField(_:forBody:)` uses, so the two would overwrite each other, and `display(_:style:)` owns tessellation that CADKit does itself. Independently of all that, `setScalarField(_:forBody:)` paints a `ScalarField` (per-face or per-triangle `Double` values + a `ColorMap`) onto a body by rebuilding it, `modelBodies[i] = _ViewportBody(id: ..., ..., triangleStyles: newStyles, ...)`, copying every other field from the current body, which mints a fresh `generation`. This is a deliberate workaround, not the design intent: `_ViewportBody.triangleStyles`'s own doc comment says an in-place mutation (`modelBodies[i].triangleStyles = newStyles`, `generation` unchanged) should be enough, but that's empirically confirmed false against `OCCTSwiftViewport`'s pinned floor, its renderer only rebuilds a body's GPU buffers (including the style buffer) when `generation` changes, so an in-place mutation on an already-rendered body silently never reaches the screen. `PickedFaceInfo.scalarValue` is resolved from the picked body's field (if any) inside `resolveFacePick`, keyed by the same `faceIndex`/`triangleIndex` the field's own `Domain` (`.perFace`/`.perTriangle`) expects. A sub-shape that reached the selection without a pick (through the context directly, or by area selection) is enriched on demand instead, with no triangle to sample, so a `.perTriangle` field reports no value for it. `setComparison(_:)` layers a `ComparisonMode` on top of two already-loaded entities: `.overlay`/`.sideBySide` mutate `color.w`/`transform` in place (both read live per frame by the renderer, unlike `triangleStyles`, so no body reconstruction needed), `.wipe` rebuilds each side's bodies keeping only the triangles on its side of a world-space plane (`wipeFiltered`), and `.deviation` is a marker only, the caller drives it via `setScalarField(_:forBody:)` on the candidate. Each call to `setComparison` first undoes whatever the previous comparison did (from a `comparisonBackup` snapshot for the body-mutating modes, or by clearing the candidate's scalar field for `.deviation`) before applying the new one, so repeated calls (e.g. a live-dragged wipe `position`) never compound. Separately, `clippingPlanes`/`addClippingPlane`/`removeClippingPlane`/`sectionSweep` push a `[ClippingPlane]` (origin + normal, `id`-addressable) to `controller.clipPlanes` (`OCCTSwiftViewport`'s global, GPU-only, up-to-4-plane hollow clip) via `syncClippingPlanes`, then call `updateCapSurfaces()`, which, for whichever planes have `showCapSurface == true`: reconstructs each affected body from a genuine B-Rep split of its ORIGINAL shape (`cappedShape`, sequentially cutting at every cap-enabled plane) rather than relying on any shader-level cap (there isn't one). `updateCapSurfaces` considers the union of every body it's currently capping and every loaded body on each call, skipping a body entirely when its cap outcome hasn't actually changed since last time (via `boundsPracticallyEqual`, only for a body still visibly showing that outcome, otherwise it restores/recaps through `replaceBody`, re-tessellating from the pristine shape `clippingSourceShapes` remembers) rather than unconditionally restoring-then-recapping everything on every call, and preserves an independently active BODY-MUTATING comparison (`.overlay`/`.sideBySide`/`.wipe`: never `.deviation`, which has no body to preserve) around its own recompute, so it composes correctly with repeated calls, with newly-loaded geometry (every loader ends by calling it too, not just the dedicated clipping-plane mutators), and with `setComparison`'s own undo step. Separately again, `present(_:) async -> EscalationResponse` is the human-in-the-loop entry point: it highlights `EscalationRequest.entities` via the SAME `select(_:scheme:)` mechanism a real pick uses (`.replace` then `.add` for each), shows any `EscalationCandidate.previewBodyID` (searching `modelBodies` then every overlay), stores an `EscalationRequest` in `pendingEscalation`, and suspends on a `CheckedContinuation` until `respond(_:)` (or the `respondWithCurrentSelection()` convenience, which wraps whatever `selection` currently holds in `.picked(...)`) resumes it. If a PREVIOUS escalation is still pending when `present` is called again, it's resolved `.deferred` first, same "undo the previous one before applying the new one" shape as `setComparison`. `remove(id:)`/`resetAllModelState()` auto-resolve a pending escalation as `.rejected` if it referenced a body that's now gone, rather than leaving a suspended `present(_:)` call stuck forever. ## Things to be careful about -- **`PadCAMEngineOCCT` import has been removed.** The original PadCAM service exposed an `OCCTGeometrySource` from the engine package; consumers that need one should construct it themselves from `loadedShape`. Don't add `PadCAMEngine`/`PadCAMEngineOCCT` back as a dependency — that's the kind of domain leak this package exists to avoid. -- **`generateLegacyMesh()` was dropped.** It returned a CAM-specific `GeometryModel` for the SceneKit-based `ScenePreviewView`. If a consumer needs raw triangle data, expose a generic mesh accessor — don't reintroduce CAM types. +- **`PadCAMEngineOCCT` import has been removed.** The original PadCAM service exposed an `OCCTGeometrySource` from the engine package; consumers that need one should construct it themselves from `loadedShape`. Don't add `PadCAMEngine`/`PadCAMEngineOCCT` back as a dependency, that's the kind of domain leak this package exists to avoid. +- **`generateLegacyMesh()` was dropped.** It returned a CAM-specific `GeometryModel` for the SceneKit-based `ScenePreviewView`. If a consumer needs raw triangle data, expose a generic mesh accessor, don't reintroduce CAM types. - **Stock display and toolpath display were dropped.** They live behind the `setOverlay(id:bodies:)` API now. Anything that's adding domain-specific methods (`setStock`, `setToolpath`, `setFlatPattern`) should be doing it in the consuming app, not here. -- **Don't write to `service.bodies` directly.** It's `private(set)` for a reason — it's a Combine-mirrored read-only view of `interactiveContext.bodies`. Mutations go through `setOverlay`/`clearOverlay` (CADKit-owned) or through `interactiveContext.display(_:)`/`appendInternalBody(_:)` (AIS-owned). The `rebuildBodies()` merge protocol depends on each writer staying in its lane. -- **The deprecated single-shape API and the multi-entity API share one `entities` registry — keep it that way.** `loadFile(from:progress:)`/`loadShape(_:id:)`/`loadFromData(_:filename:progress:)` register their own resulting body/bodies in `entities` too (one entity per body — they have no caller-supplied grouping concept of their own) via `resetAllModelState()` + a manual `entities[...] = Entity(...)` afterward, rather than being a parallel, untracked code path. An earlier version kept the two APIs tracking independently; that let `loadShape(_:id:"model")` followed by `load(_:id:"model")` render two overlapping "model" bodies (no shared registry to detect the collision), and let a multi-entity load's dict entries (`metadata`/`bodyShapes`/etc.) leak past a later deprecated-API call that only reset `modelBodies`. If you touch either loading family, keep `entities` as the one source of truth both write to. -- **`setScalarField` rebuilds the body wholesale — don't "optimize" this back to an in-place `triangleStyles` mutation without re-verifying against the renderer first.** The obvious, documented-as-correct implementation (`modelBodies[i].triangleStyles = newStyles`, leaving `generation` alone) was tried and is silently broken: empirically confirmed (via a temporary probe against `OCCTSwiftViewport`'s `OffscreenRenderer`, comparing rendered pixel output before/after mutating `triangleStyles` on an already-rendered body with `generation` unchanged — the two renders were byte-identical) that `ViewportRenderer`/`OffscreenRenderer.ensureBuffers(for:)` gate ALL buffer rebuilding, including the triangle-style buffer, behind a check that only compares `body.generation` — which an in-place mutation never changes. `OCCTSwiftAIS`'s own selection-highlight code uses the identical in-place-mutation pattern and is likely equally affected; this wasn't fixed here since it's a different repo, but it's worth knowing if selection highlighting on an already-rendered body ever looks like it "doesn't update." `removeBodies(_:)`/`resetAllModelState()` must also clear `scalarFields`/`lastScalarFieldBodyID` for any body they remove — a stale field surviving past its body's removal would (once a NEW body reuses that id) paint values against geometry they were never computed for. -- **`.wipe` filters triangles in CADKit rather than using `OCCTSwiftViewport.ViewportController.clipPlanes`, because that mechanism is viewport-global.** Confirmed via `ViewportRenderer` (`activeClipPlanes` is a single shared array clipping every body in the scene identically) and via `ViewportBody` (no per-body clip-plane field) — there's no way to clip the reference on one side and the candidate on the other using it. `CADViewportService.wipeFiltered(_:axisVector:position:keepBelow:)` instead rebuilds each body keeping only the triangles whose centroid falls on its side of the plane, reusing `vertexData`/`meshPositions`/`meshNormals` unfiltered (only `indices`/`faceIndices`/`triangleStyles` are filtered — no vertex remapping needed since indices just reference fewer entries) and dropping `edges`/`arcs`/`vertices`/`vertexIndices`/`vertexColors` (wireframe overlay and vertex-picking aren't preserved on a wiped body). If `OCCTSwiftViewport` ever grows per-body clip-plane scoping, this could move to that instead and keep wireframe/vertex-picking intact through a wipe. -- **`applyOverlay`/`applySideBySide` mutate `color`/`transform` in place — that's safe, unlike `triangleStyles`.** Confirmed via `ViewportRenderer.BodyUniforms(body:)`, which reads `body.effectiveMaterial` (hence `color`) fresh every frame, and via the many `uniforms.modelMatrix = body.transform` call sites, which read `transform` fresh every frame too — neither is baked into a `generation`-gated cached buffer the way `vertexData`/`indices`/`triangleStyles` are. Don't "fix" these to go through a full body-reconstruction pattern like `setScalarField` does; that would just be slower for no correctness gain. -- **`setComparison`'s undo path assumes exactly one comparison is active at a time.** `comparisonBackup` is a flat `[bodyID: _ViewportBody]` dict, populated before applying a new comparison and fully drained by `undoComparison` right before the next one applies (or on `nil`). `remove(id:)` (and therefore `load`/`loadFile(from:id:)`'s reuse-an-id replace path) calls `pruneComparison(removingEntityIDs:)`, which routes through `undoComparison` before clearing `comparison` — restoring whichever side of an `.overlay`/`.sideBySide`/`.wipe` comparison is still loaded (its body ids no longer match anything in `modelBodies` for the side that was just removed, so that half of the restore loop safely no-ops). An earlier version dropped `comparisonBackup` outright instead of restoring first — caught by adversarial review, since the SURVIVING side of the comparison (e.g. the reference, when only the candidate is reloaded mid-comparison) had been independently mutated and was left that way forever with no active `comparison` to explain it. -- **`applySideBySide` unions bounds across every body of an entity, not just its first.** `shape(id:)` deliberately returns only an entity's first body's shape (documented on `shape(id:)` itself) — fine for `focus(on:)`'s "roughly frame the camera" use, but wrong for computing an offset that has to actually clear every body of a multi-body entity. `applySideBySide` uses its own `entityBounds(_:)` helper (union over `entityBodyIDs(_:).compactMap { bodyShapes[$0] }`) instead of `shape(id:)`, so the gap/offset accounts for the whole entity's extent. `.overlay`/`.wipe` don't have this problem since they mutate per-body in a loop rather than computing one aggregate number from a single representative body. -- **The GPU pick pass does NOT respect `controller.clipPlanes` — CADKit's resolvers filter picks themselves.** Confirmed via `OCCTSwiftViewport`'s `Shaders.metal`: the "Clip plane discard" loop only appears in the main SHADED fragment function, not in `pick_fragment`/`pick_line_fragment`/`pick_arc_fragment`/the point-pick fragment shader — a raw GPU pick can hit geometry that's invisible on screen. `resolveFacePick`/`resolveEdgePick`/`resolveVertexPick` each compute the picked primitive's own world-space position (`triangleWorldCentroid`/`edgeSegmentWorldMidpoint`/`vertexWorldPosition`, applying `body.transform`) and test it against every enabled `ClippingPlane` (`isPointClipped`) before resolving, rather than trusting the pick pass. `isPointClipped` only tests the first 4 *enabled* planes (`.filter{$0.isEnabled}.prefix(4)`), matching `ViewportRenderer`'s own limit — a 5th+ enabled hollow-clip plane isn't actually applied by the renderer, so testing against it would reject a pick the geometry is still visibly showing. If `OCCTSwiftViewport`'s pick shaders ever grow their own clip-plane discard, this becomes redundant-but-harmless, not wrong. -- **Capping is genuine B-Rep geometry (via `OCCTSwift.Shape.split(atPlane:normal:)`), not a shader trick — because there isn't one.** Confirmed no capping/stencil logic exists in `OCCTSwiftViewport`'s `Shaders.metal` (only the plain clip-plane discard). A `showCapSurface: true` plane costs a real split + retessellation on every `syncClippingPlanes()` call, but ONLY for bodies a cap-enabled plane actually intersects — `cappedShape` returns `.unchanged` (compared via `boundsPracticallyEqual`, since `Shape.split(atPlane:normal:)` doesn't reliably return `nil` for a non-intersecting plane — it can come back with the shape unchanged wrapped in a single-element array) for every other body, which `updateCapSurfaces` then skips entirely rather than needlessly retessellating (and, via a fresh `BRepGraph`, invalidating the durable `GraphUID`s of) geometry no plane comes near. An adversarial review caught an earlier version retessellating (and identity-invalidating) EVERY loaded body whenever ANY cap-enabled plane existed. A caller doing a live-scrubbed, capped `sectionSweep` on complex geometry should still expect real cost for bodies the plane DOES intersect; `showCapSurface: false` while dragging and enabling capping only once the drag settles is the cheaper alternative for those. -- **`cappedShape`'s "which split piece is on the kept side" test, and its "did anything actually change" test, are both bounds-based heuristics, not exact geometric queries.** `Shape.split(atPlane:normal:)` documents no return-order guarantee, so `cappedShape` tests each returned piece's bounding-box center against the plane equation to pick the kept one, and compares the final result's bounds against the input's to decide `.unchanged` vs. `.capped`. Both share the same failure mode: a piece/cut whose true geometry is on the correct side (or genuinely removes material) but doesn't move the axis-aligned bounding box — e.g. a chunk that isn't at the shape's extremal point along any axis — could be misclassified. Acceptable for a review affordance; revisit with a real interior-point/volume query if a manufactured case ever hits this. -- **`replaceBody` clears `scalarFields[bodyID]`/`lastScalarFieldBodyID` on every genuine retessellation (capping OR restoring to pristine).** A cut inserts/removes faces and renumbers the rest, so a previously-set `ScalarField`'s values have no defined correspondence to the new tessellation's ordinals — left in place, `scalarValue(forBody:faceIndex:triangleIndex:)` would silently index the OLD field's values with the NEW geometry's ordinals, returning a plausible but wrong number rather than `nil`. Same failure mode `removeBodies`/`resetAllModelState` already guard against for a removed body; an adversarial review flagged the capping path was missing the equivalent guard. The caller must re-`setScalarField` after a genuine cut, same as after a reload. -- **`lastScalarFieldBodyID` falls back to another still-`scalarFields`-active body (via `dropLastScalarFieldBodyID(ifCurrently:)`) rather than going `nil` outright whenever the body it names loses its field.** All three clearing sites (`setScalarField(nil, forBody:)`, `removeBodies(_:)`, `replaceBody`) route through this one helper. An earlier version reset straight to `nil` in all three places, so `scalarFieldLegend` (whose own doc says "`nil` if no field is currently set on any body") went `nil` the moment the MOST-RECENTLY-set body's field cleared, even with a different, still-loaded body's field still actively painted — an independent review (issue #43) caught this; a regression test now covers both "clearing a non-last body's field leaves the legend alone" and "clearing the last one falls back rather than going nil." -- **Every loader ends by calling `updateCapSurfaces()`, not a bare `rebuildBodies()`, so newly-loaded geometry picks up whatever clipping/capping is already active immediately.** `loadFile(from:progress:)`, `loadShape(_:id:)`, `loadFile(from:id:progress:)`, and both exit paths of `load(_:id:transform:)` all do this (the `loadFromData` overloads inherit it by delegating to the `loadFile` ones). An earlier version left every loader ending in a bare `rebuildBodies()`, so an entity loaded while a clip plane — especially a capping one — was already active in the scene displayed uncut, or cut-but-hollow, until some UNRELATED later clip-plane mutation happened to trigger the sync; an independent review caught this (issue #44). `updateCapSurfaces()` already iterated `modelBodies` before this fix, so no change was needed there — it just needed to actually run at load time too. -- **`updateCapSurfaces` skips retessellating an ALREADY-capped body whose cap outcome hasn't actually changed, not just a body no plane has ever touched.** The `.unchanged` `CapOutcome` case (see the capping-cost bullet above) only ever protected a body that was never in `clippingCapBackup` to begin with. `updateCapSurfaces` now considers the union of `clippingCapBackup`'s keys and every currently loaded body in ONE combined loop (rather than an unconditional "restore everything in `clippingCapBackup`, then re-cap everything in `modelBodies`" two-pass structure), and for the `.capped` outcome specifically, skips the retessellation entirely when the body is already visibly showing that exact cut (`boundsPracticallyEqual` against `bodyShapes[bodyID]`, only when currently visible — a body left hidden by a since-reverted full clip always gets a real transition back, regardless of bounds, since visibility has to flip either way). An earlier version restored-then-recapped every already-capped body unconditionally on every single call, so an actively-capped body's durable `GraphUID` stopped resolving after ANY unrelated `updateCapSurfaces()`-triggering event (an unrelated plane moving, an unrelated comparison clearing) — not just a genuine re-cut of that body. An independent review caught this (issue #45); a regression test asserts both `generation` and a durable pick `uid` survive an unrelated update. -- **`updateCapSurfaces` undoes, then re-applies, an independently active `comparison` around its OWN restore/recompute — but ONLY for `.overlay`/`.sideBySide`/`.wipe`, via `comparisonNeedsBodyPreservation`, never for `.deviation`.** `.overlay`/`.sideBySide`/`.wipe` all mutate `modelBodies` directly, so preserving them across an unrelated cap recompute is correct and necessary (an adversarial review caught the ghosting-snaps-back-to-full-opacity bug this fixes — see below). `.deviation` is different: it's a marker over a `ScalarField` the caller manages independently via `setScalarField(_:forBody:)`, and `undoComparison`'s `.deviation` case is DESTRUCTIVE (`setScalarField(nil, forBody: candidateID)`) with no corresponding restore in `applyComparison` (`.deviation: break`) — that pairing is correct when `setComparison` itself drives the undo (the user is genuinely switching away from deviation), but wrong when `updateCapSurfaces` drives it just to "step aside" for an unrelated cap recompute: an independent review (issue #46, "the most serious finding" in that audit) found that ANY clipping-plane mutation anywhere in the scene — including one touching no cap-enabled planes at all — silently wiped an active `.deviation` comparison's scalar field while `comparison?.mode` kept reporting `.deviation` as still active, with no error and no signal. Fixed by having `updateCapSurfaces` treat `.deviation` as nothing-to-preserve (skip the undo/reapply pair for it entirely) rather than routing it through the same generic mechanism as the three body-mutating modes — a genuine re-cut of the candidate's OWN body still correctly clears its field, via `replaceBody`'s own guard (the bullet two above this one), which is the only case that should ever touch it. (`.sideBySide`'s reapplication legitimately recomputes a fresh offset from current entity bounds each time it DOES preserve — if a cap-enabled plane change genuinely altered the reference/candidate's geometry, the offset SHOULD change; only an offset change with no such underlying bounds change would be a compounding bug.) -- **`present(_:)`'s `CheckedContinuation` must always be resumed exactly once — every code path that can end an escalation goes through `respond(_:)`, never a direct `pendingEscalation = nil`.** `resetAllModelState()`/`pruneEscalation` both call `respond(.rejected(reason:))` rather than clearing `pendingEscalation`/`escalationContinuation` by hand, and `present` itself calls `respond(.deferred)` on a still-pending PREVIOUS request before installing the new one. Bypassing `respond(_:)` anywhere a pending escalation could be torn down would leak a continuation (a Swift concurrency runtime warning at best, a stuck caller forever awaiting `present(_:)` at worst). -- **`present(_:)` also resolves `.deferred` if the awaiting `Task` is cancelled — an adversarial review caught that the first version didn't.** Plain `withCheckedContinuation` does nothing on cancellation (it's cooperative — cancelling a `Task` never touches a bare continuation on its own), so a SwiftUI `.task` whose view disappeared, or an agent racing `present(_:)` against its own timeout and cancelling the loser, left `pendingEscalation`/the continuation stuck forever with nothing left to resolve it — confirmed empirically before the fix. Now wrapped in `withTaskCancellationHandler`, whose `onCancel` hops into `respondIfStillPending(_:with:)` via an unstructured `Task { @MainActor in ... }` (since `onCancel` isn't guaranteed to run on `MainActor` itself). That hop can't actually execute before `present`'s own synchronous continuation-setup completes — `@MainActor`'s cooperative, non-preemptive scheduling means a newly-spawned Task never preempts currently-running code on the same actor — so the ordering that matters (`escalationContinuation` set before any cancellation response can fire) always holds; `respond(_:)`'s own no-op-if-nothing-pending guard covers the case where `onCancel` fires before `present` is even called (already-cancelled on entry). -- **The cancellation `onCancel` hop resolves via `respondIfStillPending(requestID:with:)`, checking `pendingEscalation?.id` first — not a bare `respond(.deferred)`.** `onCancel` captures `request.id`, not a live reference to "whatever's currently pending." An independent review (issue #47) found a race the original cancellation fix introduced: task A is cancelled, its `onCancel` hop is merely enqueued (not yet run); before it runs, a caller legitimately calls `present(requestB)`, which correctly supersedes A via `present`'s own `respond(.deferred)` guard and installs B's own continuation; A's now-stale hop THEN finally runs — a bare `respond(.deferred)` would silently resolve B (whatever now occupies the shared `escalationContinuation`/`pendingEscalation` slot) instead of being the no-op it should be, since nothing about B was ever cancelled. No crash, no hang — just a wrong answer delivered to whoever legitimately awaited `present(requestB)`. `respondIfStillPending` only proceeds when `pendingEscalation?.id` still matches the id the cancellation was originally about; direct UI/agent callers of `respond(_:)` don't need this guard themselves, since they're always resolving whatever's actually on screen. -- **`respondIfStillPending` is `internal`, not `private`, specifically because a real-`Task`-cancellation test can't actually force the race it guards against.** A follow-up review of the #47 fix confirmed (a temporary probe, reverted) that `present(_:)`'s `onCancel` hop reliably finishes before a newly-spawned SUPERSEDING `present(_:)` task gets a turn on the same `@MainActor` serial executor — so a scheduling-based test (`staleCancellationDoesNotResolveNewerEscalation`) passes identically whether or not the id-check guard is even present, and doesn't actually prove the fix does anything. `respondIfStillPendingIgnoresSupersededRequestID` forces the exact stale-hop-after-supersession scenario deterministically instead, by calling the guard directly (mirroring why `resolveFacePick`/`resolveEdgePick`/`resolveVertexPick` are `internal` too) rather than depending on scheduling timing — confirmed (by temporarily reverting the guard) that THIS test does fail without the fix. Keep both tests: the scheduling-based one still verifies the real end-to-end cancellation pathway doesn't crash or hang under actual `Task` scheduling; only the direct one verifies the id-check itself. -- **`present(_:)` shows a candidate's preview body but never hides it again itself — that's the caller's job.** Given no explicit signal for "the user is done considering this candidate" beyond the whole escalation resolving, and no toggle-between-candidates concept in the proposal this shipped against, showing every supplied `previewBodyID` once and leaving hide/cleanup to whoever staged the body (via `setOverlay`/`clearOverlay`, or removing the entity) keeps this feature's footprint minimal rather than inventing a "currently browsing candidate N" state machine the acceptance criteria didn't ask for. +- **Don't write to `service.bodies` directly.** It's `private(set)` for a reason, it's a Combine-mirrored read-only view of `interactiveContext.bodies`. Mutations go through `setOverlay`/`clearOverlay` (CADKit-owned) or through `interactiveContext.display(_:)`/`appendInternalBody(_:)` (AIS-owned). The `rebuildBodies()` merge protocol depends on each writer staying in its lane. +- **The deprecated single-shape API and the multi-entity API share one `entities` registry, keep it that way.** `loadFile(from:progress:)`/`loadShape(_:id:)`/`loadFromData(_:filename:progress:)` register their own resulting body/bodies in `entities` too (one entity per body, they have no caller-supplied grouping concept of their own) via `resetAllModelState()` + a manual `entities[...] = Entity(...)` afterward, rather than being a parallel, untracked code path. An earlier version kept the two APIs tracking independently; that let `loadShape(_:id:"model")` followed by `load(_:id:"model")` render two overlapping "model" bodies (no shared registry to detect the collision), and let a multi-entity load's dict entries (`metadata`/`bodyShapes`/etc.) leak past a later deprecated-API call that only reset `modelBodies`. If you touch either loading family, keep `entities` as the one source of truth both write to. +- **`setScalarField` rebuilds the body wholesale, don't "optimize" this back to an in-place `triangleStyles` mutation without re-verifying against the renderer first.** The obvious, documented-as-correct implementation (`modelBodies[i].triangleStyles = newStyles`, leaving `generation` alone) was tried and is silently broken: empirically confirmed (via a temporary probe against `OCCTSwiftViewport`'s `OffscreenRenderer`, comparing rendered pixel output before/after mutating `triangleStyles` on an already-rendered body with `generation` unchanged, the two renders were byte-identical) that `ViewportRenderer`/`OffscreenRenderer.ensureBuffers(for:)` gate ALL buffer rebuilding, including the triangle-style buffer, behind a check that only compares `body.generation`: which an in-place mutation never changes. `OCCTSwiftAIS`'s own selection-highlight code uses the identical in-place-mutation pattern and is likely equally affected; this wasn't fixed here since it's a different repo, but it's worth knowing if selection highlighting on an already-rendered body ever looks like it "doesn't update." `removeBodies(_:)`/`resetAllModelState()` must also clear `scalarFields`/`lastScalarFieldBodyID` for any body they remove, a stale field surviving past its body's removal would (once a NEW body reuses that id) paint values against geometry they were never computed for. +- **`.wipe` filters triangles in CADKit rather than using `OCCTSwiftViewport.ViewportController.clipPlanes`, because that mechanism is viewport-global.** Confirmed via `ViewportRenderer` (`activeClipPlanes` is a single shared array clipping every body in the scene identically) and via `ViewportBody` (no per-body clip-plane field), there's no way to clip the reference on one side and the candidate on the other using it. `CADViewportService.wipeFiltered(_:axisVector:position:keepBelow:)` instead rebuilds each body keeping only the triangles whose centroid falls on its side of the plane, reusing `vertexData`/`meshPositions`/`meshNormals` unfiltered (only `indices`/`faceIndices`/`triangleStyles` are filtered, no vertex remapping needed since indices just reference fewer entries) and dropping `edges`/`arcs`/`vertices`/`vertexIndices`/`vertexColors` (wireframe overlay and vertex-picking aren't preserved on a wiped body). If `OCCTSwiftViewport` ever grows per-body clip-plane scoping, this could move to that instead and keep wireframe/vertex-picking intact through a wipe. +- **`applyOverlay`/`applySideBySide` mutate `color`/`transform` in place, that's safe, unlike `triangleStyles`.** Confirmed via `ViewportRenderer.BodyUniforms(body:)`, which reads `body.effectiveMaterial` (hence `color`) fresh every frame, and via the many `uniforms.modelMatrix = body.transform` call sites, which read `transform` fresh every frame too, neither is baked into a `generation`-gated cached buffer the way `vertexData`/`indices`/`triangleStyles` are. Don't "fix" these to go through a full body-reconstruction pattern like `setScalarField` does; that would just be slower for no correctness gain. +- **`setComparison`'s undo path assumes exactly one comparison is active at a time.** `comparisonBackup` is a flat `[bodyID: _ViewportBody]` dict, populated before applying a new comparison and fully drained by `undoComparison` right before the next one applies (or on `nil`). `remove(id:)` (and therefore `load`/`loadFile(from:id:)`'s reuse-an-id replace path) calls `pruneComparison(removingEntityIDs:)`, which routes through `undoComparison` before clearing `comparison`: restoring whichever side of an `.overlay`/`.sideBySide`/`.wipe` comparison is still loaded (its body ids no longer match anything in `modelBodies` for the side that was just removed, so that half of the restore loop safely no-ops). An earlier version dropped `comparisonBackup` outright instead of restoring first, caught by adversarial review, since the SURVIVING side of the comparison (e.g. the reference, when only the candidate is reloaded mid-comparison) had been independently mutated and was left that way forever with no active `comparison` to explain it. +- **`applySideBySide` unions bounds across every body of an entity, not just its first.** `shape(id:)` deliberately returns only an entity's first body's shape (documented on `shape(id:)` itself), fine for `focus(on:)`'s "roughly frame the camera" use, but wrong for computing an offset that has to actually clear every body of a multi-body entity. `applySideBySide` uses its own `entityBounds(_:)` helper (union over `entityBodyIDs(_:).compactMap { bodyShapes[$0] }`) instead of `shape(id:)`, so the gap/offset accounts for the whole entity's extent. `.overlay`/`.wipe` don't have this problem since they mutate per-body in a loop rather than computing one aggregate number from a single representative body. +- **The GPU pick pass does NOT respect `controller.clipPlanes`: CADKit's resolvers filter picks themselves.** Confirmed via `OCCTSwiftViewport`'s `Shaders.metal`: the "Clip plane discard" loop only appears in the main SHADED fragment function, not in `pick_fragment`/`pick_line_fragment`/`pick_arc_fragment`/the point-pick fragment shader, a raw GPU pick can hit geometry that's invisible on screen. `resolveFacePick`/`resolveEdgePick`/`resolveVertexPick` each compute the picked primitive's own world-space position (`triangleWorldCentroid`/`edgeSegmentWorldMidpoint`/`vertexWorldPosition`, applying `body.transform`) and test it against every enabled `ClippingPlane` (`isPointClipped`) before resolving, rather than trusting the pick pass. `isPointClipped` only tests the first 4 *enabled* planes (`.filter{$0.isEnabled}.prefix(4)`), matching `ViewportRenderer`'s own limit, a 5th+ enabled hollow-clip plane isn't actually applied by the renderer, so testing against it would reject a pick the geometry is still visibly showing. If `OCCTSwiftViewport`'s pick shaders ever grow their own clip-plane discard, this becomes redundant-but-harmless, not wrong. +- **Capping is genuine B-Rep geometry (via `OCCTSwift.Shape.split(atPlane:normal:)`), not a shader trick, because there isn't one.** Confirmed no capping/stencil logic exists in `OCCTSwiftViewport`'s `Shaders.metal` (only the plain clip-plane discard). A `showCapSurface: true` plane costs a real split + retessellation on every `syncClippingPlanes()` call, but ONLY for bodies a cap-enabled plane actually intersects, `cappedShape` returns `.unchanged` (compared via `boundsPracticallyEqual`, since `Shape.split(atPlane:normal:)` doesn't reliably return `nil` for a non-intersecting plane, it can come back with the shape unchanged wrapped in a single-element array) for every other body, which `updateCapSurfaces` then skips entirely rather than needlessly retessellating (and, via a fresh `BRepGraph`, invalidating the durable `GraphUID`s of) geometry no plane comes near. An adversarial review caught an earlier version retessellating (and identity-invalidating) EVERY loaded body whenever ANY cap-enabled plane existed. A caller doing a live-scrubbed, capped `sectionSweep` on complex geometry should still expect real cost for bodies the plane DOES intersect; `showCapSurface: false` while dragging and enabling capping only once the drag settles is the cheaper alternative for those. +- **`cappedShape`'s "which split piece is on the kept side" test, and its "did anything actually change" test, are both bounds-based heuristics, not exact geometric queries.** `Shape.split(atPlane:normal:)` documents no return-order guarantee, so `cappedShape` tests each returned piece's bounding-box center against the plane equation to pick the kept one, and compares the final result's bounds against the input's to decide `.unchanged` vs. `.capped`. Both share the same failure mode: a piece/cut whose true geometry is on the correct side (or genuinely removes material) but doesn't move the axis-aligned bounding box, e.g. a chunk that isn't at the shape's extremal point along any axis, could be misclassified. Acceptable for a review affordance; revisit with a real interior-point/volume query if a manufactured case ever hits this. +- **`replaceBody` clears `scalarFields[bodyID]`/`lastScalarFieldBodyID` on every genuine retessellation (capping OR restoring to pristine).** A cut inserts/removes faces and renumbers the rest, so a previously-set `ScalarField`'s values have no defined correspondence to the new tessellation's ordinals, left in place, `scalarValue(forBody:faceIndex:triangleIndex:)` would silently index the OLD field's values with the NEW geometry's ordinals, returning a plausible but wrong number rather than `nil`. Same failure mode `removeBodies`/`resetAllModelState` already guard against for a removed body; an adversarial review flagged the capping path was missing the equivalent guard. The caller must re-`setScalarField` after a genuine cut, same as after a reload. +- **`lastScalarFieldBodyID` falls back to another still-`scalarFields`-active body (via `dropLastScalarFieldBodyID(ifCurrently:)`) rather than going `nil` outright whenever the body it names loses its field.** All three clearing sites (`setScalarField(nil, forBody:)`, `removeBodies(_:)`, `replaceBody`) route through this one helper. An earlier version reset straight to `nil` in all three places, so `scalarFieldLegend` (whose own doc says "`nil` if no field is currently set on any body") went `nil` the moment the MOST-RECENTLY-set body's field cleared, even with a different, still-loaded body's field still actively painted, an independent review (issue #43) caught this; a regression test now covers both "clearing a non-last body's field leaves the legend alone" and "clearing the last one falls back rather than going nil." +- **Every loader ends by calling `updateCapSurfaces()`, not a bare `rebuildBodies()`, so newly-loaded geometry picks up whatever clipping/capping is already active immediately.** `loadFile(from:progress:)`, `loadShape(_:id:)`, `loadFile(from:id:progress:)`, and both exit paths of `load(_:id:transform:)` all do this (the `loadFromData` overloads inherit it by delegating to the `loadFile` ones). An earlier version left every loader ending in a bare `rebuildBodies()`, so an entity loaded while a clip plane, especially a capping one, was already active in the scene displayed uncut, or cut-but-hollow, until some UNRELATED later clip-plane mutation happened to trigger the sync; an independent review caught this (issue #44). `updateCapSurfaces()` already iterated `modelBodies` before this fix, so no change was needed there, it just needed to actually run at load time too. +- **`updateCapSurfaces` skips retessellating an ALREADY-capped body whose cap outcome hasn't actually changed, not just a body no plane has ever touched.** The `.unchanged` `CapOutcome` case (see the capping-cost bullet above) only ever protected a body that was never in `clippingCapBackup` to begin with. `updateCapSurfaces` now considers the union of `clippingCapBackup`'s keys and every currently loaded body in ONE combined loop (rather than an unconditional "restore everything in `clippingCapBackup`, then re-cap everything in `modelBodies`" two-pass structure), and for the `.capped` outcome specifically, skips the retessellation entirely when the body is already visibly showing that exact cut (`boundsPracticallyEqual` against `bodyShapes[bodyID]`, only when currently visible, a body left hidden by a since-reverted full clip always gets a real transition back, regardless of bounds, since visibility has to flip either way). An earlier version restored-then-recapped every already-capped body unconditionally on every single call, so an actively-capped body's durable `GraphUID` stopped resolving after ANY unrelated `updateCapSurfaces()`-triggering event (an unrelated plane moving, an unrelated comparison clearing), not just a genuine re-cut of that body. An independent review caught this (issue #45); a regression test asserts both `generation` and a durable pick `uid` survive an unrelated update. +- **`updateCapSurfaces` undoes, then re-applies, an independently active `comparison` around its OWN restore/recompute, but ONLY for `.overlay`/`.sideBySide`/`.wipe`, via `comparisonNeedsBodyPreservation`, never for `.deviation`.** `.overlay`/`.sideBySide`/`.wipe` all mutate `modelBodies` directly, so preserving them across an unrelated cap recompute is correct and necessary (an adversarial review caught the ghosting-snaps-back-to-full-opacity bug this fixes, see below). `.deviation` is different: it's a marker over a `ScalarField` the caller manages independently via `setScalarField(_:forBody:)`, and `undoComparison`'s `.deviation` case is DESTRUCTIVE (`setScalarField(nil, forBody: candidateID)`) with no corresponding restore in `applyComparison` (`.deviation: break`), that pairing is correct when `setComparison` itself drives the undo (the user is genuinely switching away from deviation), but wrong when `updateCapSurfaces` drives it just to "step aside" for an unrelated cap recompute: an independent review (issue #46, "the most serious finding" in that audit) found that ANY clipping-plane mutation anywhere in the scene, including one touching no cap-enabled planes at all, silently wiped an active `.deviation` comparison's scalar field while `comparison?.mode` kept reporting `.deviation` as still active, with no error and no signal. Fixed by having `updateCapSurfaces` treat `.deviation` as nothing-to-preserve (skip the undo/reapply pair for it entirely) rather than routing it through the same generic mechanism as the three body-mutating modes, a genuine re-cut of the candidate's OWN body still correctly clears its field, via `replaceBody`'s own guard (the bullet two above this one), which is the only case that should ever touch it. (`.sideBySide`'s reapplication legitimately recomputes a fresh offset from current entity bounds each time it DOES preserve, if a cap-enabled plane change genuinely altered the reference/candidate's geometry, the offset SHOULD change; only an offset change with no such underlying bounds change would be a compounding bug.) +- **`present(_:)`'s `CheckedContinuation` must always be resumed exactly once, every code path that can end an escalation goes through `respond(_:)`, never a direct `pendingEscalation = nil`.** `resetAllModelState()`/`pruneEscalation` both call `respond(.rejected(reason:))` rather than clearing `pendingEscalation`/`escalationContinuation` by hand, and `present` itself calls `respond(.deferred)` on a still-pending PREVIOUS request before installing the new one. Bypassing `respond(_:)` anywhere a pending escalation could be torn down would leak a continuation (a Swift concurrency runtime warning at best, a stuck caller forever awaiting `present(_:)` at worst). +- **`present(_:)` also resolves `.deferred` if the awaiting `Task` is cancelled, an adversarial review caught that the first version didn't.** Plain `withCheckedContinuation` does nothing on cancellation (it's cooperative, cancelling a `Task` never touches a bare continuation on its own), so a SwiftUI `.task` whose view disappeared, or an agent racing `present(_:)` against its own timeout and cancelling the loser, left `pendingEscalation`/the continuation stuck forever with nothing left to resolve it, confirmed empirically before the fix. Now wrapped in `withTaskCancellationHandler`, whose `onCancel` hops into `respondIfStillPending(_:with:)` via an unstructured `Task { @MainActor in ... }` (since `onCancel` isn't guaranteed to run on `MainActor` itself). That hop can't actually execute before `present`'s own synchronous continuation-setup completes, `@MainActor`'s cooperative, non-preemptive scheduling means a newly-spawned Task never preempts currently-running code on the same actor, so the ordering that matters (`escalationContinuation` set before any cancellation response can fire) always holds; `respond(_:)`'s own no-op-if-nothing-pending guard covers the case where `onCancel` fires before `present` is even called (already-cancelled on entry). +- **The cancellation `onCancel` hop resolves via `respondIfStillPending(requestID:with:)`, checking `pendingEscalation?.id` first, not a bare `respond(.deferred)`.** `onCancel` captures `request.id`, not a live reference to "whatever's currently pending." An independent review (issue #47) found a race the original cancellation fix introduced: task A is cancelled, its `onCancel` hop is merely enqueued (not yet run); before it runs, a caller legitimately calls `present(requestB)`, which correctly supersedes A via `present`'s own `respond(.deferred)` guard and installs B's own continuation; A's now-stale hop THEN finally runs, a bare `respond(.deferred)` would silently resolve B (whatever now occupies the shared `escalationContinuation`/`pendingEscalation` slot) instead of being the no-op it should be, since nothing about B was ever cancelled. No crash, no hang, just a wrong answer delivered to whoever legitimately awaited `present(requestB)`. `respondIfStillPending` only proceeds when `pendingEscalation?.id` still matches the id the cancellation was originally about; direct UI/agent callers of `respond(_:)` don't need this guard themselves, since they're always resolving whatever's actually on screen. +- **`respondIfStillPending` is `internal`, not `private`, specifically because a real-`Task`-cancellation test can't actually force the race it guards against.** A follow-up review of the #47 fix confirmed (a temporary probe, reverted) that `present(_:)`'s `onCancel` hop reliably finishes before a newly-spawned SUPERSEDING `present(_:)` task gets a turn on the same `@MainActor` serial executor, so a scheduling-based test (`staleCancellationDoesNotResolveNewerEscalation`) passes identically whether or not the id-check guard is even present, and doesn't actually prove the fix does anything. `respondIfStillPendingIgnoresSupersededRequestID` forces the exact stale-hop-after-supersession scenario deterministically instead, by calling the guard directly (mirroring why `resolveFacePick`/`resolveEdgePick`/`resolveVertexPick` are `internal` too) rather than depending on scheduling timing, confirmed (by temporarily reverting the guard) that THIS test does fail without the fix. Keep both tests: the scheduling-based one still verifies the real end-to-end cancellation pathway doesn't crash or hang under actual `Task` scheduling; only the direct one verifies the id-check itself. +- **`present(_:)` shows a candidate's preview body but never hides it again itself, that's the caller's job.** Given no explicit signal for "the user is done considering this candidate" beyond the whole escalation resolving, and no toggle-between-candidates concept in the proposal this shipped against, showing every supplied `previewBodyID` once and leaving hide/cleanup to whoever staged the body (via `setOverlay`/`clearOverlay`, or removing the entity) keeps this feature's footprint minimal rather than inventing a "currently browsing candidate N" state machine the acceptance criteria didn't ask for. ## Files -- `Sources/OCCTSwiftCADKit/CADViewportService.swift` — the service -- `Sources/OCCTSwiftCADKit/CADViewportView.swift` — SwiftUI wrapper -- `Sources/OCCTSwiftCADKit/PickedFaceInfo.swift` — `PickedFaceInfo` and `FaceBounds` (the local face-bounds type that replaced PadCAM's `DetectedSurface.SurfaceBounds` — keep it here) -- `Sources/OCCTSwiftCADKit/PickedEntity.swift` — `PickedEntity` (the face/edge/vertex pick union), `PickedEdgeInfo`, `PickedVertexInfo` -- `Sources/OCCTSwiftCADKit/SelectionSummary.swift` — aggregate measures (count/area/length/bounds) over a multi-selection -- `Sources/OCCTSwiftCADKit/ScalarField.swift` — `ScalarField`, `ColorMap` (and its color-sampling math), `ScalarFieldLegend`, `LegendStop` -- `Sources/OCCTSwiftCADKit/ComparisonView.swift` — `ComparisonView`, `ComparisonMode`, `Axis` (mesh/solid comparison display) -- `Sources/OCCTSwiftCADKit/ClippingPlane.swift` — `ClippingPlane` (clipping/section planes, hollow or solid-capped) -- `Sources/OCCTSwiftCADKit/EscalationRequest.swift` — `EscalationRequest`, `EscalationCandidate`, `EscalationResponse` (human-in-the-loop escalation) -- `Sources/OCCTSwiftCADKit/EscalationCardView.swift` — SwiftUI presentation for an `EscalationRequest` -- `Sources/OCCTSwiftCADKit/CADViewportError.swift` — error type -- `Tests/OCCTSwiftCADKitTests/SmokeTests.swift` — value-type smoke tests (no viewport I/O) +- `Sources/OCCTSwiftCADKit/CADViewportService.swift`: the service +- `Sources/OCCTSwiftCADKit/CADViewportView.swift`: SwiftUI wrapper +- `Sources/OCCTSwiftCADKit/PickedFaceInfo.swift`: `PickedFaceInfo`, `FaceBounds` (the local face-bounds type that replaced PadCAM's `DetectedSurface.SurfaceBounds`: keep it here), and `isSamePick`, the one identity rule the three info types share +- `Sources/OCCTSwiftCADKit/PickedEntity.swift`: `PickedEntity` (the face/edge/vertex pick union, no whole-body case), `PickedEdgeInfo`, `PickedVertexInfo`, each storing a `SubShapeRef` and forwarding identity to it +- `Sources/OCCTSwiftCADKit/SelectionMeasurements.swift`: aggregate measures (count/area/length/bounds) over a multi-selection, plus the deprecated `SelectionSummary` alias +- `Sources/OCCTSwiftCADKit/ScalarField.swift`: `ScalarField`, `ColorMap` (and its color-sampling math), `ScalarFieldLegend`, `LegendStop` +- `Sources/OCCTSwiftCADKit/ComparisonView.swift`: `ComparisonView`, `ComparisonMode`, `Axis` (mesh/solid comparison display) +- `Sources/OCCTSwiftCADKit/ClippingPlane.swift`: `ClippingPlane` (clipping/section planes, hollow or solid-capped) +- `Sources/OCCTSwiftCADKit/EscalationRequest.swift`: `EscalationRequest`, `EscalationCandidate`, `EscalationResponse` (human-in-the-loop escalation) +- `Sources/OCCTSwiftCADKit/EscalationCardView.swift`: SwiftUI presentation for an `EscalationRequest` +- `Sources/OCCTSwiftCADKit/CADViewportError.swift`: error type +- `Tests/OCCTSwiftCADKitTests/SmokeTests.swift`: value-type smoke tests (no viewport I/O) diff --git a/docs/reference/CADViewportService.md b/docs/reference/CADViewportService.md index c467116..c6473db 100644 --- a/docs/reference/CADViewportService.md +++ b/docs/reference/CADViewportService.md @@ -1,11 +1,11 @@ -# CADViewportService — API reference +# CADViewportService, API reference `OCCTSwiftCADKit` provides a shared SwiftUI Metal CAD viewport: import STEP/STL/BREP -geometry, render it, route face/edge/vertex-picking results — single or multi-select — +geometry, render it, route face/edge/vertex-picking results, single or multi-select, back to your app, and paint scalar fields (deviation heatmaps and similar) over a body. The public surface is `CADViewportService` (the controller/state owner), `CADViewportView` (the SwiftUI view), the `PickedEntity`/`PickedFaceInfo`/`PickedEdgeInfo`/`PickedVertexInfo`/ -`SelectionSummary`/`ScalarField`/`ColorMap`/`ScalarFieldLegend`/`LegendStop`/`FaceBounds` +`SelectionMeasurements`/`ScalarField`/`ColorMap`/`ScalarFieldLegend`/`LegendStop`/`FaceBounds` result types, and `CADViewportError`. ```swift @@ -68,15 +68,16 @@ let custom = CADViewportService(configuration: .init( | Property | Type | Description | | --- | --- | --- | -| `controller` | `_ViewportController` | Viewport controller — camera, display mode, picking config. Bind into `CADViewportView`. | +| `controller` | `_ViewportController` | Viewport controller, camera, display mode, picking config. Bind into `CADViewportView`. | | `interactiveContext` | `InteractiveContext` | AIS interactive context backed by this viewport. Install `ManipulatorWidget`, dimensions, or extra `InteractiveObject`s here; appended bodies are composited with the CADKit-owned bodies. | | `bodies` | `[_ViewportBody]` | All bodies currently displayed: model bodies + overlay layers + selection highlight + AIS-owned bodies. Read-only; mirrors `interactiveContext.bodies`. | | `loadedShape` | `OCCTSwift.Shape?` | **Deprecated**, use `loadedShapes`/`shape(id:)`. Non-nil only when exactly one entity is loaded, however it was loaded. | | `loadedShapes` | `[String: OCCTSwift.Shape]` | Multi-entity loads, keyed by entity id (see [Multi-body / assembly](#multi-body--assembly)). | -| `selection` | `[PickedEntity]` | Every currently selected face/edge/vertex. Read-only; a real pick replaces it wholesale — build a multi-selection with `select(_:scheme:)`. | -| `selectionSummary` | `SelectionSummary?` | Aggregate measures over `selection`: count by kind, total area/length, combined bounds. `nil` when empty. | +| `selection` | `[PickedEntity]` | Every currently selected face/edge/vertex, projected from `interactiveContext.selection`. Read-only; a real pick replaces it wholesale, and a multi-selection is built with `select(_:scheme:)`. Ordered by (body id, kind, ordinal). | +| `selectionMeasurements` | `SelectionMeasurements?` | Aggregate measures over `selection`: count by kind, total area/length, combined bounds. `nil` when empty. | +| `selectionSummary` | `SelectionMeasurements?` | **Deprecated**, renamed to `selectionMeasurements`. | | `selected` | `PickedEntity?` | **Deprecated**, use `selection`. Non-nil only when the selection is exactly one entity. | -| `selectionModes` | `Set` | Which sub-shape kinds picking resolves. Defaults to `[.face]`. | +| `selectionModes` | `Set` | Which sub-shape kinds picking resolves. The same state as `interactiveContext.selectionMode`, initialised to `[.face]`. | | `selectedFace` | `PickedFaceInfo?` | **Deprecated**, use `selection`. Non-nil only when the selection is exactly one face. | | `shapeBounds` | `ShapeBounds?` | Axis-aligned bounds of the single loaded shape, or `nil` (see `loadedShape`'s single-entity caveat, and the [void-bounds note](#void-bounding-boxes)). | | `overlayIDs` | `[String]` | Sorted ids of overlay layers currently staged. | @@ -86,7 +87,7 @@ let custom = CADViewportService(configuration: .init( ### File import (single-shape, deprecated) `loadFile(from:progress:)`, `loadShape(_:id:)`, and `loadFromData(_:filename:progress:)` -each **replace every model body**, including any loaded via the multi-entity API below — +each **replace every model body**, including any loaded via the multi-entity API below, safe to mix with it, since both register in the same internal entity registry. See [Multi-body / assembly](#multi-body--assembly) for loading several parts or an assembly. @@ -101,8 +102,8 @@ extension selects the format: `.step`/`.stp` → STEP, `.stl` → STL, `.brep` The camera is automatically focused on the shape's bounding box. - **Parameters:** - - `url` — file URL on disk. - - `progress` — optional `ImportProgress` (e.g. `ImportProgressClosure`) to observe + - `url`: file URL on disk. + - `progress`: optional `ImportProgress` (e.g. `ImportProgressClosure`) to observe STEP/IGES import progress and request cooperative cancellation. - **Returns:** the first loaded `OCCTSwift.Shape`. - **Throws:** `CADViewportError.unsupportedFormat(ext)` for any other extension; @@ -160,7 +161,7 @@ try await viewport.loadFromData(data, filename: pickedURL.lastPathComponent) ### Multi-body / assembly -Several parts — or several of an assembly's occurrences — can display simultaneously as +Several parts, or several of an assembly's occurrences, can display simultaneously as distinct, addressable **entities**, rather than one replacing another. Shares one entity registry with the deprecated single-shape overloads above (see their note), so `loadedShapes`/`visibility`/`removeAll()`/`entityID(forBodyID:)` see everything currently @@ -177,18 +178,18 @@ public func loadFile(from url: URL, id: String, progress: ImportProgress? = nil) public func loadFromData(_ data: Data, filename: String, id: String, progress: ImportProgress? = nil) async throws -> String ``` -- **`id`** — the entity id. Loading again under an id already in use replaces that +- **`id`**, the entity id. Loading again under an id already in use replaces that entity. Required on every overload (unlike the deprecated `loadFile(from:progress:)` - family's implicit single entity) — a defaulted `id` would make e.g. + family's implicit single entity), a defaulted `id` would make e.g. `loadFile(from: url)` ambiguous against the deprecated 2-argument overload. -- **`transform`** (`load` only) — places the shape before tessellating it. A rigid +- **`transform`** (`load` only), places the shape before tessellating it. A rigid 12-element affine matrix matching `OCCTSwift.Shape.transformed(matrix:)`'s layout: `[r00,r01,r02, r10,r11,r12, r20,r21,r22, tx,ty,tz]` (row-major 3x3 rotation, then translation). `nil` (default) leaves the shape as-is. - **Returns:** `id`, echoed back. - A file with several bodies (e.g. a multibody STEP/STL) registers as **one** entity whose underlying body ids are `"-0"`, `"-1"`, etc. -- Camera is **not** auto-focused (unlike the deprecated single-shape overloads) — call +- Camera is **not** auto-focused (unlike the deprecated single-shape overloads), call `focus(on:)` explicitly. ```swift @@ -225,10 +226,10 @@ for hit in viewport.selection { ``` **Memory behavior:** each occurrence loaded via `load(_:id:transform:)` is tessellated -independently — v1 does not deduplicate geometry across repeated instances of the same +independently, v1 does not deduplicate geometry across repeated instances of the same part (an assembly's shared-definition/occurrence model is not implemented). Measured on this machine: 1245 occurrences of a plain 10×8×6mm box (a synthetic proxy, not the actual -reference corpus's own geometry) cost ~646 MB resident memory, ~0.52 MB/occurrence — real +reference corpus's own geometry) cost ~646 MB resident memory, ~0.52 MB/occurrence, real parts will cost more per occurrence than this proxy. Sharing tessellated geometry across occurrences of the same product would very likely reduce memory substantially for an assembly with many repeated instances of a small number of unique products; worth a @@ -265,8 +266,8 @@ public func select(_ entity: PickedEntity, scheme: SelectionScheme = .replace) ``` `clearSelection()` empties `selection` and removes the highlight bodies. A real viewport -pick always calls `select(_:scheme: .replace)` internally — matching `OCCTSwiftAIS`'s own -point-pick behavior — so `selection` becomes `[thatOneEntity]` on every plain pick. +pick always calls `select(_:scheme: .replace)` internally, matching `OCCTSwiftAIS`'s own +point-pick behavior, so `selection` becomes `[thatOneEntity]` on every plain pick. `selectionModes` (default `[.face]`) gates which kinds resolve; add `.edge`/`.vertex` to opt into edge/vertex picking, or remove `.face` to disable face picking. @@ -295,13 +296,19 @@ Each highlight is visually distinguishable by kind: a translucent yellow triangl aggregating every selected face's own triangles, a bright cyan polyline aggregating every selected edge's segments, a bright magenta point sprite per selected vertex. A body whose `ViewportBody` has no `edgeIndices`/`vertices` populated (not edge/vertex pickable) simply -never produces an edge/vertex pick on that body — face picking on the same body is +never produces an edge/vertex pick on that body, face picking on the same body is unaffected. -`SelectionMode` is `OCCTSwiftAIS.SelectionMode` (the same type -`InteractiveContext.selectionMode` uses), but `selectionModes` is an independent -selection system — the two don't share state, and `SelectionMode.body` has no effect -here (there's no whole-body `PickedEntity` case). +`selectionModes` **is** `interactiveContext.selectionMode`, not a copy of it: reading or +writing either reads or writes the other. It is initialised to `[.face]` at `init`, +overriding the interactive context's own `[.body]` default. Assigning a different set +clears the selection, which is the interactive context's documented behaviour for +`selectionMode` and now applies here too. + +`SelectionMode.body` selects a `SubShape.body` in the interactive context, for objects +displayed there directly via `interactiveContext.display(_:style:)`. It still produces no +`PickedEntity`, because there is no whole-body case: read `interactiveContext.selection` +for those. This property is the sub-shape projection. @@ -315,12 +322,18 @@ Build a selection spanning more than one entity with `select(_:scheme:)`: public func select(_ entity: PickedEntity, scheme: SelectionScheme = .replace) ``` -- **`scheme`** — `OCCTSwiftAIS.SelectionScheme` (`.replace`/`.add`/`.remove`/`.xor`), the +- **`scheme`**, `OCCTSwiftAIS.SelectionScheme` (`.replace`/`.add`/`.remove`/`.xor`), the same combination semantics `selectRectangle`/`selectPolygon` area selection uses on the AIS side. `.replace` (default) assigns `selection = [entity]`; `.add` appends if not - already present; `.remove` drops it; `.xor` toggles it. Membership uses `PickedEntity`'s - own `uid`-preferring `Equatable`, so the same durable face/edge/vertex is recognized as - already-selected regardless of which ephemeral ordinal it was picked at this time. + already present; `.remove` drops it; `.xor` toggles it. Membership is `SubShapeRef`'s + own `uid`-preferring rule, which `PickedEntity`'s `Equatable` mirrors, so the same + durable face/edge/vertex is recognized as already-selected regardless of which ephemeral + ordinal it was picked at this time. + +`select(_:scheme:)` delegates to `interactiveContext.select(_:scheme:)`, which holds the +selection. A pick on a body the interactive context displays itself is left alone rather +than treated as an unresolved pick, so an AIS-displayed object can stay selected; a pick on +empty space still clears the whole shared selection. ```swift viewport.select(faceA) // selection = [faceA] @@ -331,18 +344,21 @@ viewport.select(faceB, scheme: .xor) // selection = [] The selection survives operations unrelated to it: `remove(id:)`/`removeAll()` drop only the selection entries that referenced the removed entity's bodies, leaving the rest -selected — the selection honestly reports what's gone by no longer containing it, rather -than either lingering on stale picks or being wiped wholesale for an unrelated change. +selected: the selection reports what's gone by no longer containing it, rather than either +lingering on stale picks or being wiped wholesale for an unrelated change. ```swift -public var selectionSummary: SelectionSummary? { get } +public var selectionMeasurements: SelectionMeasurements? { get } ``` -Aggregate measures over the current selection — see [`SelectionSummary`](#selectionsummary). -`nil` when `selection` is empty. +Aggregate measures over the current selection, see +[`SelectionMeasurements`](#selectionmeasurements). `nil` when `selection` is empty. Named +`selectionSummary` (returning a `SelectionSummary`) until OCCTSwiftInteraction#3; both old +spellings still resolve, deprecated, because `OCCTSwiftUXKit` has an unrelated public +`SelectionSummary` of its own. ```swift -if let summary = viewport.selectionSummary { +if let summary = viewport.selectionMeasurements { print(summary.faceCount, summary.edgeCount, summary.vertexCount) print(summary.totalArea, summary.totalLength) print(summary.bounds) @@ -358,17 +374,17 @@ public var scalarFieldLegend: ScalarFieldLegend? { get } ``` `setScalarField` paints (or, with `nil`, clears) a scalar value per face or per triangle -over a loaded body — deviation, curvature, wall thickness, confidence: anything indexed by +over a loaded body, deviation, curvature, wall thickness, confidence: anything indexed by face ordinal or triangle. **Current cost:** this rebuilds the whole body (a fresh `generation`), not just its GPU `TriangleStyle` buffer. `OCCTSwiftViewport`'s own `ViewportBody.triangleStyles` is documented to support a cheap in-place mutation instead (`generation` unchanged, only the -style buffer re-uploads) — but that was empirically confirmed to silently not update an +style buffer re-uploads), but that was empirically confirmed to silently not update an already-rendered body against `OCCTSwiftViewport`'s currently-pinned floor: its renderer only rebuilds a body's GPU buffers when `generation` changes, and an in-place `triangleStyles` mutation never changes it. This is a workaround for what looks like an -upstream caching bug, tracked as a known limitation — `setScalarField`'s own signature +upstream caching bug, tracked as a known limitation, `setScalarField`'s own signature won't need to change if/when it's fixed upstream. ```swift @@ -381,12 +397,12 @@ let field = ScalarField( unit: "mm" ) viewport.setScalarField(field, forBody: "candidate") -viewport.scalarField(forBody: "candidate") // ScalarField? — round-trips what was set +viewport.scalarField(forBody: "candidate") // ScalarField?, round-trips what was set viewport.setScalarField(nil, forBody: "candidate") // clears it ``` `scalarFieldLegend` reports the most recently set (still-active) field's label, unit, -range, and evenly-spaced color stops — read it to render a color bar with real tick +range, and evenly-spaced color stops, read it to render a color bar with real tick labels; an unlabelled heatmap is decorative. Removing/replacing a body clears its field; if that body was the one the legend was tracking, it falls back to another still-active field on a different body if one exists, and only goes `nil` once no body has an active field at @@ -399,7 +415,7 @@ if let legend = viewport.scalarFieldLegend { } ``` -A face pick reports its scalar value directly (`PickedFaceInfo.scalarValue: Double?`) — +A face pick reports its scalar value directly (`PickedFaceInfo.scalarValue: Double?`), see [`PickedFaceInfo`](#pickedfaceinfo). `nil` when no field is set on that body. See [`ScalarField`](#scalarfield) / [`ColorMap`](#colormap) / [`ScalarFieldLegend`](#scalarfieldlegend) @@ -412,8 +428,8 @@ public private(set) var comparison: ComparisonView? public func setComparison(_ comparison: ComparisonView?) ``` -Displays two already-loaded entities against each other — typically a source mesh -(`referenceID`) and a reconstructed solid (`candidateID`) — for reconstruction review. +Displays two already-loaded entities against each other, typically a source mesh +(`referenceID`) and a reconstructed solid (`candidateID`), for reconstruction review. Settable and clearable repeatedly (including switching to a different mode, or a different `position`/`referenceOpacity` for the same mode) without reloading either entity: each call first undoes whatever the previous comparison did before applying the new one. @@ -428,11 +444,11 @@ viewport.setComparison(nil) // restores both entities' plain display ``` - **`.overlay(referenceOpacity:)`** ghosts the reference by lowering its bodies' alpha - (clamped to `0...1`). An in-place mutation of `_ViewportBody.color` — safe, since the + (clamped to `0...1`). An in-place mutation of `_ViewportBody.color`: safe, since the renderer reads `color` fresh into its per-frame uniforms rather than caching it behind `generation` the way `triangleStyles` is (see the [scalar fields](#scalar-fields) section above for that distinction). -- **`.deviation`** is a marker only — CADKit doesn't compute the reference-to-candidate +- **`.deviation`** is a marker only, CADKit doesn't compute the reference-to-candidate distance itself. Call `setScalarField(_:forBody:)` on the candidate with the precomputed values first; this mode just records that deviation display is active, so clearing it (`setComparison(nil)`, or switching to a different mode) also clears the @@ -446,7 +462,7 @@ viewport.setComparison(nil) // restores both entities' plain display coordinate along `axis` is less than `position`, the candidate where it's greater or equal. Implemented by filtering each body's own triangles (not `ViewportController.clipPlanes`, which clips the whole scene uniformly and can't show - the two sides differently) — see `CLAUDE.md`'s "Things to be careful about" for why, and + the two sides differently), see `CLAUDE.md`'s "Things to be careful about" for why, and what a wiped body loses (wireframe edges, vertex-picking) as a result. `comparison` reports the currently active `ComparisonView`, or `nil`. Removing (or @@ -465,7 +481,7 @@ public func removeClippingPlane(id: String) public func sectionSweep(axis: SIMD3, position: Double) ``` -Clipping/section planes — hides geometry on one side, interactively, and (with +Clipping/section planes, hides geometry on one side, interactively, and (with `showCapSurface: true`, the default) shows the cut as solid material rather than hollow. ```swift @@ -475,23 +491,23 @@ viewport.removeClippingPlane(id: planeID) ``` - **Clipping itself** pushes every plane in `clippingPlanes` to `OCCTSwiftViewport`'s - `ViewportController.clipPlanes` — a global, GPU-only mechanism (up to 4 *enabled* planes, + `ViewportController.clipPlanes`: a global, GPU-only mechanism (up to 4 *enabled* planes, applied uniformly to the whole scene every frame). This part is instant/interactive regardless of geometry complexity, and is what makes `sectionSweep` safe to call on every frame of a scrub when `showCapSurface: false`. -- **Capping** (`showCapSurface: true`) is NOT a shader trick — `OCCTSwiftViewport` has no - shader-level capping — so it's a genuine `OCCTSwift.Shape.split(atPlane:normal:)` and +- **Capping** (`showCapSurface: true`) is NOT a shader trick, `OCCTSwiftViewport` has no + shader-level capping, so it's a genuine `OCCTSwift.Shape.split(atPlane:normal:)` and retessellation, sequentially against every cap-enabled plane, for each body a cap-enabled - plane actually intersects (bodies nowhere near any plane are left completely untouched — + plane actually intersects (bodies nowhere near any plane are left completely untouched, no retessellation, no durable-identity churn). This is real geometry work, not a cheap GPU operation for the bodies it does touch: expect a per-body cost proportional to the shape's complexity on every `clippingPlanes` mutation. A caller doing a live, capped `sectionSweep` scrub on complex geometry that plane actually cuts through should expect this cost, or switch to `showCapSurface: false` while dragging and enable capping only once the drag settles. A body with an active `ScalarField` (`setScalarField(_:forBody:)`) loses it the - moment that body is genuinely cut — the field's ordinals don't correspond to the new + moment that body is genuinely cut, the field's ordinals don't correspond to the new tessellation, so it's cleared rather than silently mispainted; re-`setScalarField` after. -- **Multiple planes compose** — both for hollow clipping (native to +- **Multiple planes compose**, both for hollow clipping (native to `controller.clipPlanes`) and for capping (`clippingPlanes` filtered to `showCapSurface == true` are applied as a sequential chain of splits, so the visible remainder is their intersection). @@ -499,13 +515,13 @@ viewport.removeClippingPlane(id: planeID) a face/edge/vertex pick's own world-space position is tested against every enabled plane (up to the first 4, matching the renderer's own limit) before it resolves, so clipped-away geometry can't be picked and the surfaces a clip reveals pick normally. -- **Reloading picks up an already-active cap immediately** — every loader (`load`/ +- **Reloading picks up an already-active cap immediately**, every loader (`load`/ `loadFile`/`loadShape`/`loadFromData`, including reloading under an id already in use) ends by syncing clipping state, so newly-loaded (or freshly-reloaded) geometry never displays uncut or hollow-without-a-cap until some unrelated later clipping-plane call happens to trigger the sync. - An independently active `setComparison(_:)` comparison on the same entity survives a - clipping-plane change (and vice versa) for `.overlay`/`.sideBySide`/`.wipe` — each properly + clipping-plane change (and vice versa) for `.overlay`/`.sideBySide`/`.wipe`: each properly undoes and reapplies around the other's recompute, rather than one silently discarding the other's mutation. `.deviation` doesn't need this treatment at all: it has no body for clipping to preserve, and is left untouched by any clipping-plane change (a genuine re-cut @@ -524,7 +540,7 @@ public func respondWithCurrentSelection() ``` The runtime half of a human-in-the-loop model: ask a bounded question grounded in specific -geometry, and await an answer — by candidate choice, by picking geometry instead, by +geometry, and await an answer, by candidate choice, by picking geometry instead, by deferring, or by rejecting. ```swift @@ -544,20 +560,20 @@ let response = await viewport.present(request) `present(_:)`: - Highlights `request.entities` via the same mechanism a real pick uses - (`select(_:scheme:)` — `.replace` then `.add` for each), replacing the current `selection`. + (`select(_:scheme:)`: `.replace` then `.add` for each), replacing the current `selection`. - Shows any supplied `EscalationCandidate.previewBodyID` (searched across `modelBodies` and - every overlay layer) — but doesn't hide it again itself once the escalation resolves; + every overlay layer), but doesn't hide it again itself once the escalation resolves; that's the caller's responsibility, same as it owns staging the preview body in the first place. - Sets `pendingEscalation`, then suspends until answered. - If a PREVIOUS escalation is still pending, resolves it `.deferred` first, so calling - `present(_:)` again is always safe — no leaked continuation, no silently-abandoned prior + `present(_:)` again is always safe, no leaked continuation, no silently-abandoned prior question. - If the awaiting `Task` is cancelled (a SwiftUI `.task` whose view disappeared, an agent racing this against its own timeout), resolves `.deferred` on its own too, rather than leaving `pendingEscalation` stuck with nothing left to resolve it. -The answer arrives however the caller's UI wires it up — typically a button in +The answer arrives however the caller's UI wires it up, typically a button in [`EscalationCardView`](#escalationcardview) calling `respond(_:)` with the appropriate case, or `respondWithCurrentSelection()` for "the human picked something instead of choosing a candidate": @@ -569,8 +585,8 @@ viewport.respond(.rejected(reason: "not enough context")) viewport.respondWithCurrentSelection() // wraps viewport.selection in .picked(...) ``` -Removing (or reloading) an entity any of the pending escalation's `entities` belongs to — -or a full `removeAll()` — auto-resolves it `.rejected("referenced geometry was removed")` +Removing (or reloading) an entity any of the pending escalation's `entities` belongs to, +or a full `removeAll()`: auto-resolves it `.rejected("referenced geometry was removed")` rather than leaving `present(_:)` suspended over geometry that no longer exists. See [`EscalationRequest`](#escalationrequest) / [`EscalationCandidate`](#escalationcandidate) / @@ -610,7 +626,7 @@ rather than substituting a default: | `focus(on:)` | Skips that entity. No-ops entirely if no listed entity has bounds, leaving the camera where it is. | | Auto-focus after a deprecated single-shape load | No-op, same reasoning. | | `shapeBounds` | `nil`. | -| `selectionSummary` | That face contributes no bounds (it still counts toward `faceCount`/`totalArea`); `bounds` is `nil` if nothing contributed any. | +| `selectionMeasurements` | That face contributes no bounds (it still counts toward `faceCount`/`totalArea`); `bounds` is `nil` if nothing contributed any. | | Face picking | Resolves to `nil`: a face with no bounding box cannot have produced the rendered triangle that was picked. | | Capping (`clippingPlanes` with `showCapSurface`) | A split piece with no bounding box is treated as clipped away, not kept. | @@ -640,10 +656,10 @@ public init( ) ``` -- **`bodies`** — the bodies to render; pass `service.bodies`. -- **`controller`** — the viewport controller; pass `service.controller`. -- **`selection`** — the selected entities to show in the banner; pass `service.selection`. -- **`onClearSelection`** — invoked by the banner's close button; wire to `service.clearSelection()`. +- **`bodies`**, the bodies to render; pass `service.bodies`. +- **`controller`**, the viewport controller; pass `service.controller`. +- **`selection`**, the selected entities to show in the banner; pass `service.selection`. +- **`onClearSelection`**, invoked by the banner's close button; wire to `service.clearSelection()`. The built-in controls set `controller.displayMode` (`.shaded`, `.shadedWithEdges`, `.wireframe`) and call `controller.goToStandardView(.isometricFrontRight)`. @@ -658,8 +674,8 @@ CADViewportView( ``` The banner shows the single entity's description when `selection.count == 1`, or "N -selected" for a larger selection — for a richer multi-selection summary, build your own -UI from `service.selectionSummary` alongside `CADViewportView`. +selected" for a larger selection, for a richer multi-selection summary, build your own +UI from `service.selectionMeasurements` alongside `CADViewportView`. Two deprecated overloads still work for callers not yet migrated: `init(bodies:controller: selected:onClearSelection:)` (wraps a single `PickedEntity?` into `selection`) and @@ -677,10 +693,10 @@ public struct EscalationCardView: View ``` Presents an `EscalationRequest`'s question, candidates, and context, and reports how it was -answered via closures — same explicit-values-plus-callbacks style as `CADViewportView` (no +answered via closures, same explicit-values-plus-callbacks style as `CADViewportView` (no direct binding to `CADViewportService`), so the caller stays in control of how it's presented (a sheet, a sidebar inspector, a bottom card). Capped to a comfortable phone-width -column (`maxWidth: 360`) rather than separate macOS/iOS view types — usable as a floating +column (`maxWidth: 360`) rather than separate macOS/iOS view types, usable as a floating panel on a larger surface too. ### Initializer @@ -696,12 +712,12 @@ public init( ) ``` -- **`request`** — the escalation to present; pass `service.pendingEscalation` once non-`nil`. -- **`selection`** — pass `service.selection`; the "Use selection" button is disabled when empty. -- **`onChoose`** — invoked with a candidate's `id` when tapped. -- **`onUseSelection`** — invoked when the human answers by picking instead of choosing. -- **`onDefer`** / **`onReject`** — invoked by their respective buttons (`onReject` always - passes `nil` — pass a specific reason yourself if your UI collects one). +- **`request`**, the escalation to present; pass `service.pendingEscalation` once non-`nil`. +- **`selection`**, pass `service.selection`; the "Use selection" button is disabled when empty. +- **`onChoose`**, invoked with a candidate's `id` when tapped. +- **`onUseSelection`**, invoked when the human answers by picking instead of choosing. +- **`onDefer`** / **`onReject`**, invoked by their respective buttons (`onReject` always + passes `nil`: pass a specific reason yourself if your UI collects one). ```swift if let request = viewport.pendingEscalation { @@ -733,7 +749,7 @@ public enum PickedEntity: Sendable, Equatable { A pick result generalised over which kind of sub-shape was hit. `CADViewportService.selection` is `[PickedEntity]`. Every case's payload shares the same durable-identity shape (`shape`/`uid`, plus an ephemeral render-path ordinal). `bodyID` reads whichever case's `bodyID` field, -regardless of kind — pass it to `entityID(forBodyID:)` to find which multi-entity load (if +regardless of kind, pass it to `entityID(forBodyID:)` to find which multi-entity load (if any) owns the picked body. ## `PickedFaceInfo` @@ -756,12 +772,12 @@ public struct PickedFaceInfo: Sendable, Equatable { Metadata about a face picked in the viewport. `shape` and `uid` are the durable identity of the pick, captured once at pick time from the picked body's `FaceIdentityTable`. -`faceIndex` is the ephemeral render-path ordinal the pick came from — valid only against +`faceIndex` is the ephemeral render-path ordinal the pick came from, valid only against that body's own tessellation. Don't subscript `loadedShape.faces()[faceIndex]` to re-derive the face: once a face is shared between two shells, that non-deduplicating traversal counts the shared face once per shell, so the same ordinal can silently name a different face than the one actually picked. Construct a `Face` from `shape` instead. -`scalarValue` is resolved from `setScalarField(_:forBody:)`'s field at pick time — `nil` +`scalarValue` is resolved from `setScalarField(_:forBody:)`'s field at pick time, `nil` unless a field is set on this body. No CAM- or unfold-specific dependencies. ```swift @@ -824,10 +840,10 @@ if case .vertex(let vertex)? = viewport.selection.first, viewport.selection.coun } ``` -## `SelectionSummary` +## `SelectionMeasurements` ```swift -public struct SelectionSummary: Sendable, Equatable { +public struct SelectionMeasurements: Sendable, Equatable { public let faceCount: Int public let edgeCount: Int public let vertexCount: Int @@ -837,14 +853,20 @@ public struct SelectionSummary: Sendable, Equatable { } ``` -Aggregate measures over `CADViewportService.selection`, returned by `selectionSummary`. -`bounds` combines every selected entity's own bounds (a face/edge's geometric bounds, or a -vertex's position as a zero-size bounds). It is `nil` when the selection is empty, in which -case `selectionSummary` itself is `nil` too, and also when no selected entity contributed a -bounding box (see [Void bounding boxes](#void-bounding-boxes)). +Aggregate measures over `CADViewportService.selection`, returned by +`selectionMeasurements`. `bounds` combines every selected entity's own bounds (a face/edge's +geometric bounds, or a vertex's position as a zero-size bounds). It is `nil` when the +selection is empty, in which case `selectionMeasurements` itself is `nil` too, and also when +no selected entity contributed a bounding box (see +[Void bounding boxes](#void-bounding-boxes)). + +Named `SelectionSummary` until OCCTSwiftInteraction#3, when it was renamed to stop colliding +with the unrelated public `OCCTSwiftUXKit.SelectionSummary` (the selection pill's caption and +SF Symbol, which shares no field, no input and no consumer with this). The old name remains +as a deprecated typealias. ```swift -if let summary = viewport.selectionSummary { +if let summary = viewport.selectionMeasurements { print("\(summary.faceCount) faces, \(summary.edgeCount) edges, \(summary.vertexCount) vertices") print("total area:", summary.totalArea, "total length:", summary.totalLength) if let b = summary.bounds { print("size:", b.sizeX, b.sizeY, b.sizeZ) } @@ -896,24 +918,24 @@ public enum ColorMap: Sendable, Equatable { } ``` -- **`.viridis`/`.magma`/`.turbo`** — sequential ramps (dark→light), for an unsigned +- **`.viridis`/`.magma`/`.turbo`**, sequential ramps (dark→light), for an unsigned magnitude (curvature, thickness, confidence). Approximate reproductions of the published matplotlib/Google colormaps of the same name (anchor-color interpolation for - viridis/magma; Google's published polynomial fit for turbo) — close enough for review + viridis/magma; Google's published polynomial fit for turbo), close enough for review purposes, not colorimetrically exact. -- **`.diverging(center:)`** — a two-sided blue→white→red ramp about `center`, scaled by the +- **`.diverging(center:)`**, a two-sided blue→white→red ramp about `center`, scaled by the larger of `range`'s distance to `center` on either side. Use for signed deviation: material outside the source and material missing from it are different failures, and a one-ended ramp hides which is which. -- **`.threshold(levels:)`** — discrete bands: `levels` are the ascending boundaries between +- **`.threshold(levels:)`**, discrete bands: `levels` are the ascending boundaries between them (e.g. `[0.5, 1.0]` → 3 bands). Colors cycle through a small built-in pass(green)/warn(yellow)/caution(orange)/fail(red)/purple palette, repeating if there are more bands than colors. -- **`.custom(stops:)`** — explicit `(value, color)` stops (raw values, not normalized 0–1), +- **`.custom(stops:)`**, explicit `(value, color)` stops (raw values, not normalized 0–1), linearly interpolated between the two bracketing stops; clamped to the nearest stop's color outside their span. -`color(for:in:)` is what `setScalarField`/`scalarFieldLegend` call internally — call it +`color(for:in:)` is what `setScalarField`/`scalarFieldLegend` call internally, call it yourself to preview a color map without setting a field. ## `ScalarFieldLegend` @@ -927,7 +949,7 @@ public struct ScalarFieldLegend: Sendable, Equatable { } ``` -Everything a UI needs to render a scalar field's legend — a color bar with real tick +Everything a UI needs to render a scalar field's legend, a color bar with real tick labels, not a decorative gradient. Returned by `CADViewportService.scalarFieldLegend`. ```swift @@ -1017,7 +1039,7 @@ public struct EscalationRequest: Sendable, Identifiable, Equatable { ``` A bounded question about specific geometry, presented via -`CADViewportService.present(_:)`. `entities` is what the question is about — highlighted +`CADViewportService.present(_:)`. `entities` is what the question is about, highlighted when presented. `candidates` may be empty when the only sensible answer is "pick the right geometry yourself." `context` is free-form supporting data (measurements, gate output) for display alongside the question. @@ -1035,7 +1057,7 @@ public struct EscalationCandidate: Sendable, Identifiable, Equatable { One candidate answer. `previewBodyID` is the id of an already-loaded/staged `_ViewportBody` (a model body, or one staged via `setOverlay(id:bodies:)`) to show while this candidate is -being considered — `nil` if this candidate has no preview geometry of its own. +being considered, `nil` if this candidate has no preview geometry of its own. ## `EscalationResponse` @@ -1050,7 +1072,7 @@ public enum EscalationResponse: Sendable, Equatable { How an `EscalationRequest` was answered. `.chose` names one of the request's `candidates` by id; `.picked` is the human answering by selecting geometry instead; `.deferred` and -`.rejected` are distinguishable non-answers — deferring means "ask me again later," +`.rejected` are distinguishable non-answers, deferring means "ask me again later," rejecting means "the question itself doesn't apply." ## `FaceBounds` diff --git a/docs/reference/InteractiveContext.md b/docs/reference/InteractiveContext.md index 917a68b..8c357e9 100644 --- a/docs/reference/InteractiveContext.md +++ b/docs/reference/InteractiveContext.md @@ -5,7 +5,7 @@ parent: API Reference # InteractiveContext -The per-scene interactive state object — one `InteractiveContext` to one `ViewportController`. It owns +The per-scene interactive state object, one `InteractiveContext` to one `ViewportController`. It owns the array of `ViewportBody`s rendered by `MetalViewportView`, the current selection / hover, the presentation styles, and the dimension registry. `@MainActor`, `ObservableObject`. @@ -21,7 +21,7 @@ Bind it via `MetalViewportView(controller: ctx.viewport, bodies: $ctx.bodies)` w ## Topics -- [Published properties](#published-properties) · [display(_:style:)](#display_style) · [update(_:to:absorbing:operationName:)](#update_toabsorbingoperationname) · [remove(_:)](#remove_) · [removeAll()](#removeall) · [Selection mutation](#selection-mutation) · [Selection filters](#selection-filters) · [Area selection](#area-selection) · [setStyle(_:for:)](#setstyle_for) · [setHighlightStyle(_:)](#sethighlightstyle_) · [add(_:)](#add_) · [remove(_:)-dimension](#remove_-dimension) · [dimensions](#dimensions) · [refreshDimensionMeasurement(_:)](#refreshdimensionmeasurement_) · [remap(_:using:rebindingTo:)](#remap_usingrebindingto) · [isDeleted(_:in:)](#isdeleted_in) +- [Published properties](#published-properties) · [display(_:style:)](#display_style) · [update(_:to:absorbing:operationName:)](#update_toabsorbingoperationname) · [remove(_:)](#remove_) · [removeAll()](#removeall) · [Selection mutation](#selection-mutation) · [displaysBody(withID:)](#displaysbodywithid) · [Selection filters](#selection-filters) · [Area selection](#area-selection) · [setStyle(_:for:)](#setstyle_for) · [setHighlightStyle(_:)](#sethighlightstyle_) · [add(_:)](#add_) · [remove(_:)-dimension](#remove_-dimension) · [dimensions](#dimensions) · [refreshDimensionMeasurement(_:)](#refreshdimensionmeasurement_) · [remap(_:using:rebindingTo:)](#remap_usingrebindingto) · [isDeleted(_:in:)](#isdeleted_in) --- @@ -36,12 +36,12 @@ public let viewport: ViewportController public var highlightStyle: HighlightStyle // default .default ``` -- `bodies` — the bodies fed to `MetalViewportView`; bind via `$bodies`. -- `selectionMode` — what kinds of pick produce a selection. **Changing it clears the current +- `bodies`: the bodies fed to `MetalViewportView`; bind via `$bodies`. +- `selectionMode`: what kinds of pick produce a selection. **Changing it clears the current selection.** -- `selection` — the current selection (read-only; mutate via `select` / `deselect` / `clearSelection` +- `selection`: the current selection (read-only; mutate via `select` / `deselect` / `clearSelection` or a pick). Observable. -- `hover` — the currently hovered sub-shape (body granularity today), or `nil`. +- `hover`: the currently hovered sub-shape (body granularity today), or `nil`. - **Example:** ```swift @@ -62,7 +62,7 @@ Display a shape with topology-aware selection enabled. Tessellates the `Shape`, public func display(_ shape: Shape, style: PresentationStyle = .default) -> InteractiveObject ``` -- **Parameters:** `shape` — the OCCTSwift `Shape`; `style` — initial presentation style. +- **Parameters:** `shape`: the OCCTSwift `Shape`; `style`: initial presentation style. - **Returns:** the `InteractiveObject` scene handle. - **Example:** @@ -71,17 +71,17 @@ let part = ais.display(Shape.box(width: 10, height: 5, depth: 3)!, style: .highlighted) ``` -`display` also builds a `BRepGraph` from `shape` and retains it for the object's lifetime — see +`display` also builds a `BRepGraph` from `shape` and retains it for the object's lifetime, see `update(_:to:absorbing:operationName:)`, below, for what that's for. --- ## update(_:to:absorbing:operationName:) -Update a displayed object after a modelling operation that rebuilds its shape — a boolean, a fillet, a +Update a displayed object after a modelling operation that rebuilds its shape, a boolean, a fillet, a chamfer, anything produced via one of OCCTSwift's `*WithFullHistory` methods run against `object.shape`. Absorbs the operation's history into the object's living `BRepGraph` (built once in `display`, -retained across every subsequent `update` call — the input and result share one graph instance, so +retained across every subsequent `update` call, the input and result share one graph instance, so every `SubShapeRef.uid` already held stays resolvable), rebuilds the displayed mesh, and remaps any current `selection` / `hover` sub-shapes referencing `object` forward via `remap(_:using:rebindingTo:)`. @@ -95,11 +95,11 @@ public func update( ) -> InteractiveObject? ``` -- **Parameters:** `object` — the currently-displayed object being mutated; `newShape` — the operation's - result; `history` — the handle returned alongside it by any `*WithFullHistory` method; `operationName` - — a label recorded on every emitted history record. +- **Parameters:** `object`: the currently-displayed object being mutated; `newShape`: the operation's + result; `history`: the handle returned alongside it by any `*WithFullHistory` method; + `operationName`: a label recorded on every emitted history record. - **Returns:** the updated `InteractiveObject` (same `id`, new `shape`), or `nil` if `object` isn't - displayed, has no living graph (construction failed at `display` time), or the absorb fails — in any + displayed, has no living graph (construction failed at `display` time), or the absorb fails, in any of those cases, `remove` and `display` fresh, accepting that the selection doesn't survive. - **Example:** @@ -149,11 +149,19 @@ ais.removeAll() Add, remove, or clear sub-shapes. `select` / `deselect` use `Set` semantics (idempotent). ```swift -public func select(_ subshape: SubShape) -public func deselect(_ subshape: SubShape) +public func select(_ subshape: SubShape) // == scheme: .add +public func select(_ subshape: SubShape, scheme: SelectionScheme) +public func deselect(_ subshape: SubShape) // == scheme: .remove public func clearSelection() ``` +- **`scheme`**: `.replace` assigns, `.add` inserts if absent, `.remove` drops it, `.xor` toggles + it. The same `SelectionScheme` semantics `selectRectangle` / `selectPolygon` use over a whole + match set. +- **No default value on `scheme`**, deliberately. Defaulting it to `.replace` would silently + retune every existing `select(x)` call site from add to replace; `select(_:)` keeps its + original meaning and forwards to `.add`. + - **Example:** ```swift @@ -162,12 +170,33 @@ let face2 = part.shape.subShape(type: .face, index: 2)! ais.select(.face(part, ref: SubShapeRef(shape: face0, ordinal: 0))) // additive ais.select(.face(part, ref: SubShapeRef(shape: face2, ordinal: 2))) ais.deselect(.face(part, ref: SubShapeRef(shape: face0, ordinal: 0))) +ais.select(.face(part, ref: SubShapeRef(shape: face2, ordinal: 2)), scheme: .replace) ais.clearSelection() ``` -In practice most selections come from a pick — `handlePick` mints the `SubShapeRef` (uid included) +In practice most selections come from a pick, `handlePick` mints the `SubShapeRef` (uid included) for you. +**This is the package's only selection store.** `OCCTSwiftCADKit.CADViewportService` drives it +rather than keeping one of its own (OCCTSwiftInteraction#3): its `selection` is this one +projected into `PickedEntity` values, and its `selectionModes` **is** `selectionMode`. Mutating +either side is visible from the other. + +--- + +## displaysBody(withID:) + +```swift +public func displaysBody(withID bodyID: String) -> Bool +``` + +Whether `bodyID` names a body this context displays as a selectable `InteractiveObject`, that is, +one added via `display(_:style:)`. False for internal bodies (manipulator handles, dimensions), +which are not selectable objects, and for bodies a host composited into `bodies` itself. + +For a host that shares this context's selection and clears it on an unresolved pick, this is the +check that stops it from wiping a selection it never owned. + --- ## Selection filters @@ -183,7 +212,7 @@ public func removeFilter(_ filter: any SelectionFilter) // by reference identi public func removeAllFilters() ``` -- Installed filters combine with **AND** (a deliberate departure from OCCT's OR — see +- Installed filters combine with **AND** (a deliberate departure from OCCT's OR, see [Selection Filters](SelectionFilters.md) for the rationale). Never gates programmatic `select(_:)`. - **Example:** @@ -196,7 +225,7 @@ ais.removeAllFilters() ## Area selection -Rectangle and lasso selection over a screen-space region — honours `selectionMode` and installed +Rectangle and lasso selection over a screen-space region, honours `selectionMode` and installed `filters` exactly like a point pick. See [Area Selection](AreaSelection.md) for `AreaSelectionMode`, `SelectionScheme`, and the SwiftUI gesture integration (`AreaSelectionController`, `.attachAreaSelection(_:)`). @@ -224,7 +253,7 @@ ais.selectRectangle(from: CGPoint(x: 100, y: 100), to: CGPoint(x: 400, y: 300), ## setStyle(_:for:) -Restyle a displayed object in place — updates the underlying `ViewportBody`'s color and visibility. +Restyle a displayed object in place, updates the underlying `ViewportBody`'s color and visibility. ```swift public func setStyle(_ style: PresentationStyle, for object: InteractiveObject) @@ -337,7 +366,7 @@ ais.refreshDimensionMeasurement(lin) Remap a `Selection` whose sub-shapes were captured against an earlier shape state into a new `Selection` against `newObject`, using history absorbed into `graph` via `BRepGraph.add(_:absorbing:inputRoots:operationName:)`. This is the lower-level primitive -`update(_:to:absorbing:operationName:)` calls internally — reach for it directly only if you're +`update(_:to:absorbing:operationName:)` calls internally, reach for it directly only if you're managing the `BRepGraph` yourself rather than going through `update`. ```swift @@ -348,12 +377,12 @@ public func remap( ) -> Selection ``` -- **Parameters:** `selection` — the pre-mutation selection; `graph` — the `BRepGraph` that absorbed - the operation's history (input and result must share this one instance); `newObject` — the +- **Parameters:** `selection`: the pre-mutation selection; `graph`: the `BRepGraph` that absorbed + the operation's history (input and result must share this one instance); `newObject`: the post-mutation scene object the result references. -- **Returns:** a `Selection` against `newObject`, resolved through each sub-shape's `SubShapeRef.uid` — +- **Returns:** a `Selection` against `newObject`, resolved through each sub-shape's `SubShapeRef.uid`, never a stored index. `1 → 1` (modified in place) keeps the same node re-resolved to a fresh uid; - `1 → N` (e.g. a face split by a cut) expands into N entries; `1 → 0` (deleted) is dropped — see + `1 → N` (e.g. a face split by a cut) expands into N entries; `1 → 0` (deleted) is dropped, see `isDeleted(_:in:)`. A sub-shape with no `uid` is dropped: there's nothing durable to resolve it by. `.body(_)` always rebinds to `newObject`. - **Example:** @@ -367,7 +396,7 @@ for sub in remapped.subshapes { ais.select(sub) } ## isDeleted(_:in:) -Whether a sub-shape's durable node was explicitly consumed by history absorbed into `graph` — as +Whether a sub-shape's durable node was explicitly consumed by history absorbed into `graph`: as opposed to simply never being mentioned by any recorded operation. `remap`'s silent drop can't tell these apart on its own; both look like "absent from the result." @@ -376,7 +405,7 @@ public func isDeleted(_ subshape: SubShape, in graph: BRepGraph) -> Bool ``` - **Returns:** `false` for `.body` sub-shapes, and for any sub-shape with no `uid` or whose `uid` isn't - `graph`'s own — there's no node in `graph` to ask about. + `graph`'s own, there's no node in `graph` to ask about. - **Example:** ```swift diff --git a/okf/components/OCCTSwiftAIS.md b/okf/components/OCCTSwiftAIS.md index 6633a33..962e02f 100644 --- a/okf/components/OCCTSwiftAIS.md +++ b/okf/components/OCCTSwiftAIS.md @@ -19,6 +19,12 @@ Its API surface groups into: selection-mode gate and the whole-body fallback, both selection-mode decisions rather than identity ones. `SubShapeRef` / `SubShape` / `InteractiveObject` moved down to `OCCTSwiftTools` with source-compatible typealiases left behind. + **`InteractiveContext.selection` is the package's only selection store since + OCCTSwiftInteraction#3**: `OCCTSwiftCADKit.CADViewportService` drives it rather than keeping a + parallel one, so `CADViewportService.selection` is a projection of it and + `CADViewportService.selectionModes` is `selectionMode` itself. `select(_:scheme:)` gained the + four-scheme parameter from CADKit's version; `select(_:)` is unchanged and still means `.add`. + `displaysBody(withID:)` lets such a host tell its own composited bodies from this context's. - **Manipulator widgets**: translate / rotate gizmos with `snapTranslate` / `snapRotateDeg` on the renderer's overlay layer; SwiftUI integration via `.attachManipulator(_:)`. - **Dimensions**: `LinearDimension`, `AngularDimension`, `RadialDimension` with topology-aware diff --git a/okf/components/OCCTSwiftCADKit.md b/okf/components/OCCTSwiftCADKit.md index b2df517..4008470 100644 --- a/okf/components/OCCTSwiftCADKit.md +++ b/okf/components/OCCTSwiftCADKit.md @@ -13,11 +13,16 @@ timestamp: 2026-06-22 - **`CADViewportService`**: `@Observable @MainActor` service that owns the loaded geometry, drives the Metal viewport, and routes picking results via `selection: [PickedEntity]`, gated by - `selectionModes: Set` (default `[.face]`; add `.edge`/`.vertex` to opt in). A real - pick replaces `selection` wholesale; build a multi-selection with `select(_:scheme:)` - (`SelectionScheme`: `.replace`/`.add`/`.remove`/`.xor`, mirroring `OCCTSwiftAIS`'s type of the same - name). `selectionSummary` reports aggregate count/area/length/bounds. `selectedFace: PickedFaceInfo?` - and `selected: PickedEntity?` still work as deprecated single-selection conveniences. + `selectionModes: Set` (default `[.face]`; add `.edge`/`.vertex` to opt in). + **It holds no selection state of its own since OCCTSwiftInteraction#3**: `selection` is + `interactiveContext.selection` projected into `PickedEntity` values (ordered by body id, kind + and ordinal, since the store is a `Set`), and `selectionModes` **is** + `interactiveContext.selectionMode`. A real pick replaces the selection wholesale; build a + multi-selection with `select(_:scheme:)` (`SelectionScheme`: `.replace`/`.add`/`.remove`/`.xor`, + `OCCTSwiftAIS`'s own type, which gained the scheme parameter from this service). + `selectionMeasurements` reports aggregate count/area/length/bounds. `selectedFace: + PickedFaceInfo?`, `selected: PickedEntity?` and `selectionSummary` still work as deprecated + conveniences. `load(_:id:transform:)` / `loadFile(from:id:progress:)` / `loadFromData(_:filename:id:progress:)` display several parts or assembly occurrences as distinct, addressable **entities** (`loadedShapes`, `shape(id:)`, `entityID(forBodyID:)`, `visibility`, `remove(id:)`, `removeAll()`, @@ -37,18 +42,25 @@ timestamp: 2026-06-22 `CADViewportView`. One adaptive layout capped to a comfortable phone-width column, usable as a floating panel on a larger surface too. - **`PickedEntity`**: `.face(PickedFaceInfo)` / `.edge(PickedEdgeInfo)` / `.vertex(PickedVertexInfo)`, - plus a `bodyID` accessor common to all three (pass to `entityID(forBodyID:)`). + plus `bodyID` and `ref` accessors common to all three (pass `bodyID` to `entityID(forBodyID:)`). + Deliberately no whole-body case: whole-body selection is a `SubShape.body` in the interactive + context, where AIS's whole-body fallback and body-level highlight already live. - **`PickedFaceInfo`, `PickedEdgeInfo`, `PickedVertexInfo`, `FaceBounds`**: metadata returned by picking (no CAM- or unfold-specific deps). Each info type's `.shape`/`.uid` are the durable identity of the pick, resolved by `OCCTSwiftTools.SubShapePickResolver` since OCCTSwiftInteraction#2 (which reads the body's `FaceIdentityTable`/`EdgeIdentityTable`/`VertexIdentityTable`); - `.faceIndex`/`.edgeIndex`/`.vertexIndex` are ephemeral render-path ordinals only. + `.faceIndex`/`.edgeIndex`/`.vertexIndex` are ephemeral render-path ordinals only. Since + OCCTSwiftInteraction#3 each stores a `SubShapeRef` as `ref` and forwards `.shape`/`.uid`/the + ordinal to it, so identity is the resolver's, not a parallel copy of it. `PickedFaceInfo.scalarValue` is the picked face's value from the body's `ScalarField`, if any. The clip-plane pre-filter and the descriptive enrichment around each info type stay here on purpose: clip planes are this service's state, and enrichment is presentation, so neither belongs in the shared resolver. -- **`SelectionSummary`**: count by kind, total face area, total edge length, and combined bounds - over `CADViewportService.selection`. +- **`SelectionMeasurements`**: count by kind, total face area, total edge length, and combined + bounds over `CADViewportService.selection`. Named `SelectionSummary` until + OCCTSwiftInteraction#3, renamed to stop colliding with the unrelated public + `OCCTSwiftUXKit.SelectionSummary` (a UI caption type sharing no field, input or consumer with + it). The old name remains as a deprecated typealias. - **`ScalarField`, `ColorMap`, `ScalarFieldLegend`, `LegendStop`**: a per-face/per-triangle scalar value plus how it maps to color (`.viridis`/`.magma`/`.turbo` sequential, `.diverging(center:)` for signed values, `.threshold(levels:)` for discrete bands, `.custom(stops:)`), and the legend a UI