Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: 357 tests across 32 suites, all passing.**
**Expected baseline: 360 tests across 32 suites, all passing.**

## Face identity is `IsSame`, and that decision is settled

Expand Down Expand Up @@ -142,10 +142,19 @@ down by geometry, not by index, and it was mutation-checked.
shape to a BREP string: measured at 5.0ms against a 14-face solid whose mesh takes 9.6ms. Headless
consumers of `load` (OCCTDesignLoop's reprojection, batch render and parts extraction) never pick.

**A table's index space is the ordinal space, and that is load-bearing**
([#9](https://github.com/SecondMouseAU/OCCTSwiftInteraction/issues/9)). Ordinals index the *full*
enumeration, because that is what the mesher walks, so the face and edge tables are built with
`map`, never `compactMap`: a dropped element moves every later ordinal down one and the table then
names the sub-shape after the one the pick hit, silently. That is why `FaceIdentityTable.shapes` and
`EdgeIdentityTable.shapes` are `[Shape?]`. `VertexIdentityTable.shapes` is `[Shape]` because
`subShapes(ofType: .vertex)` needs no failable conversion; keep that asymmetry, in both directions.

The bridge's edge-polyline-only branch (`mesh(...)` returned nil) used to substitute an empty
`FaceIdentityTable` and now builds the ordinary one. It is reachable from tests only through the
internal `edgePolylineOnlyBridge` seam, because a wire, an edge and a lone vertex all mesh to an
empty `Mesh` rather than to nil.
empty `Mesh` rather than to nil. `ShapeIdentity.init` has an internal seam of the same kind, taking
the two sub-shape conversions as parameters, because no public API can make one of them fail.

## One selection, held by `InteractiveContext`

Expand Down Expand Up @@ -218,11 +227,22 @@ Per-type reference for all three targets is in [docs/reference/](docs/reference/
Covenant text.
- **Release pattern**: commit, push, tag, and create a GitHub release with notes.

## Platforms are iOS and macOS, and that is settled

`Package.swift` declares `.iOS(.v18)` and `.macOS(.v15)`. Nothing else, and do not add anything
else: `OCCT.xcframework`'s `Info.plist` carries exactly three slices, `ios-arm64`,
`ios-arm64-simulator` and `macos-arm64`, supporting two platforms, and OCCTSwift's own v3.0.0
release notes open with "macOS / iOS (device + simulator)". Anything linking the kernel on visionOS
or tvOS cannot link at all.

The manifest declared `.visionOS(.v1)` and `.tvOS(.v18)` until the 1.0.0 sweep, inherited from the
union of what the three pre-merge manifests declared. The claim was never true for any of the three.
Root cause is filed upstream as
[OCCTSwift#978](https://github.com/SecondMouseAU/OCCTSwift/issues/978).

## Still to finish after the merge

- `okf/index.md` describes the OCCTSwiftTools half in more detail than the other two.
- The CADKit target's `visionOS`/`tvOS` build is unverified. `Package.swift` keeps the union of what
the three declared, and CADKit declared only iOS and macOS. Confirm or narrow before 1.0.0.

## Ecosystem context worth reading before non-trivial changes

Expand Down
17 changes: 11 additions & 6 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,20 @@ func occtDep(_ name: String, from version: String) -> Package.Dependency {
// Modeled on OCCTSwiftUX, which has vended six targets from one package since well before this.
let package = Package(
name: "OCCTSwiftInteraction",
// The union of what the three carried. OCCTSwiftTools and OCCTSwiftAIS both declared
// visionOS/tvOS; OCCTSwiftCADKit declared only iOS/macOS. Keeping the union rather than the
// intersection avoids regressing the two targets with the most dependents, but it does mean the
// CADKit target's visionOS/tvOS build is unverified. Confirm or narrow before 1.0.0.
// iOS and macOS, and only those two. `OCCT.xcframework`'s `Info.plist` carries exactly three
// slices, `ios-arm64`, `ios-arm64-simulator` and `macos-arm64`, supporting two platforms, and
// OCCTSwift's own v3.0.0 release notes open with "macOS / iOS (device + simulator)". Anything
// linking the kernel on visionOS or tvOS cannot link at all.
//
// This manifest declared `.visionOS(.v1)` and `.tvOS(.v18)` until the 1.0.0 sweep, taking the
// union of what OCCTSwiftTools, OCCTSwiftAIS and OCCTSwiftCADKit declared during the merge so
// as not to regress the two targets with the most dependents. That reasoning was wrong in a way
// invisible from the manifests: the wider claim was never true for any of the three, so there
// was nothing to regress. Root cause is filed upstream as
// https://github.com/SecondMouseAU/OCCTSwift/issues/978.
platforms: [
.iOS(.v18),
.macOS(.v15),
.visionOS(.v1),
.tvOS(.v18),
],
products: [
.library(name: "OCCTSwiftTools", targets: ["OCCTSwiftTools"]),
Expand Down
15 changes: 10 additions & 5 deletions Sources/OCCTSwiftTools/EdgeIdentityTable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,25 @@ public struct EdgeIdentityTable: Sendable {
/// Built from `Shape.edges()`, the same `TopTools_IndexedMapOfShape` traversal
/// `Shape.edge(at:)` and the bulk edge-polyline extractor use, so `shapes[ordinal]` is always
/// the exact edge behind the segments carrying that ordinal.
public let shapes: [Shape]
///
/// An element is `nil` when that edge's `Edge` to `Shape` conversion failed. Optional for the
/// same reason `FaceIdentityTable.shapes` is: a shorter array would move every later ordinal
/// down one and name the wrong edge (OCCTSwiftInteraction#9).
public let shapes: [Shape?]

/// Durable per-ordinal handle, minted from the `BRepGraph` supplied when the table was built.
///
/// `nil` when no graph was supplied. When present, an individual element is `nil` only if
/// that ordinal's edge could not be resolved in the graph.
/// `nil` when no graph was supplied. When present, an individual element is `nil` if that
/// ordinal's edge could not be resolved in the graph, or has no entry in `shapes`.
public let uids: [BRepGraph.GraphUID?]?

public init(shapes: [Shape], uids: [BRepGraph.GraphUID?]? = nil) {
public init(shapes: [Shape?], uids: [BRepGraph.GraphUID?]? = nil) {
self.shapes = shapes
self.uids = uids
}

/// The `Shape` a render-path edge ordinal was extracted from.
/// The `Shape` a render-path edge ordinal was extracted from, or `nil` if the ordinal is out
/// of range or its conversion failed when the table was built.
public func shape(forOrdinal ordinal: Int) -> Shape? {
shapes.indices.contains(ordinal) ? shapes[ordinal] : nil
}
Expand Down
16 changes: 11 additions & 5 deletions Sources/OCCTSwiftTools/FaceIdentityTable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,20 +59,26 @@ public struct FaceIdentityTable: Sendable {
/// Built from `Shape.faces()`, the same enumeration the mesher uses to assign that ordinal,
/// so `shapes[ordinal]` is always the exact face tessellated into the triangles carrying that
/// ordinal.
public let shapes: [Shape]
///
/// An element is `nil` when that face's `Face` to `Shape` conversion failed. Optional so that
/// the index space is the ordinal space by construction: a shorter array would silently move
/// every later face down one and name the wrong one (OCCTSwiftInteraction#9). A `nil` entry
/// costs that one ordinal its captured shape and its uid, and no other.
public let shapes: [Shape?]

/// Durable per-ordinal handle, minted from the `BRepGraph` supplied when the table was built.
///
/// `nil` when no graph was supplied. When present, an individual element is `nil` only if
/// that ordinal's face could not be resolved in the graph.
/// `nil` when no graph was supplied. When present, an individual element is `nil` if that
/// ordinal's face could not be resolved in the graph, or has no entry in `shapes`.
public let uids: [BRepGraph.GraphUID?]?

public init(shapes: [Shape], uids: [BRepGraph.GraphUID?]? = nil) {
public init(shapes: [Shape?], uids: [BRepGraph.GraphUID?]? = nil) {
self.shapes = shapes
self.uids = uids
}

/// The `Shape` a render-path face ordinal was tessellated from.
/// The `Shape` a render-path face ordinal was tessellated from, or `nil` if the ordinal is out
/// of range or its conversion failed when the table was built.
public func shape(forOrdinal ordinal: Int) -> Shape? {
shapes.indices.contains(ordinal) ? shapes[ordinal] : nil
}
Expand Down
54 changes: 48 additions & 6 deletions Sources/OCCTSwiftTools/ShapeIdentity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ import OCCTSwift
/// - **Vertices**: `Shape.subShapes(ofType: .vertex)`, the traversal behind
/// `ViewportBody.vertexIndices`.
///
/// The face and edge enumerations need a `Face`/`Edge` to `Shape` conversion on the way, and both
/// conversions are failable, so those two tables hold `[Shape?]` and a failed conversion becomes a
/// hole at its own ordinal rather than a missing element (OCCTSwiftInteraction#9). The vertex
/// enumeration returns `Shape` values already and needs no conversion, which is why it alone holds
/// `[Shape]`.
///
/// Face identity keys on OCCT's `TopoDS_Shape::IsSame` (settled in
/// [OCCTSwiftInteraction#1](https://github.com/SecondMouseAU/OCCTSwiftInteraction/issues/1)), so a
/// face shared between two shells is one entry rather than two, and `graph.findNode(for:)` matches
Expand Down Expand Up @@ -89,10 +95,43 @@ public struct ShapeIdentity: Sendable {
/// - shape: the shape to enumerate.
/// - graph: a graph built from `shape`, or `nil` for shapes-only tables.
public init(shape: Shape, graph: BRepGraph?) {
self.init(
shape: shape, graph: graph, faceToShape: Shape.fromFace, edgeToShape: Shape.fromEdge)
}

/// The construction itself, with the two failable sub-shape conversions supplied by the caller.
///
/// Internal rather than private so a test can drive a conversion failure, which no public API
/// can provoke: every `Face` and `Edge` the enumerations hand back already holds a live OCCT
/// handle, so the bridge calls behind `Shape.fromFace` / `Shape.fromEdge` do not fail for one.
/// Same treatment, and same reason, as `CADFileLoader.edgePolylineOnlyBridge`.
///
/// - Parameters:
/// - shape: the shape to enumerate.
/// - graph: a graph built from `shape`, or `nil` for shapes-only tables.
/// - faceToShape: how a `Face` becomes a `Shape`, `Shape.fromFace` in production.
/// - edgeToShape: how an `Edge` becomes a `Shape`, `Shape.fromEdge` in production.
init(
shape: Shape,
graph: BRepGraph?,
faceToShape: (Face) -> Shape?,
edgeToShape: (Edge) -> Shape?
) {
self.shape = shape
self.graph = graph
let faceShapes = shape.faces().compactMap { Shape.fromFace($0) }
let edgeShapes = shape.edges().compactMap { Shape.fromEdge($0) }
// `map`, never `compactMap`. These arrays are indexed by the render-path ordinal, which
// the mesher assigns from the unfiltered enumeration, so dropping a failed conversion
// would move every later ordinal down one and the table would then name the sub-shape
// after the one the pick hit (OCCTSwiftInteraction#9). A `nil` element means "no identity
// at this ordinal", which costs one face its durable handle instead of corrupting its
// neighbours, and `SubShapePickResolver` already reads that as a miss and re-derives from
// the shape.
let faceShapes = shape.faces().map(faceToShape)
let edgeShapes = shape.edges().map(edgeToShape)
// The vertex path takes no conversion at all: `subShapes(ofType:)` returns `Shape` values,
// so its alignment holds for free and its table stays non-optional. That asymmetry is the
// point, so do not add a conversion here to match the other two, and do not take the other
// two back to `compactMap` to match this one.
let vertexShapes = shape.subShapes(ofType: .vertex)
self.faces = FaceIdentityTable(
shapes: faceShapes, uids: Self.uids(for: faceShapes, in: graph))
Expand All @@ -119,12 +158,15 @@ public struct ShapeIdentity: Sendable {
///
/// Written once and shared by all three kinds. `findNode(for:)` matches on OCCT's `IsSame`,
/// which is the identity these tables are enumerated by, so a face shared between two shells
/// resolves to the one node naming it. An individual element is `nil` only when that
/// sub-shape has no node in the graph.
private static func uids(for shapes: [Shape], in graph: BRepGraph?) -> [BRepGraph.GraphUID?]? {
/// resolves to the one node naming it. An individual element is `nil` when that sub-shape has
/// no node in the graph, and also when there is no sub-shape at that ordinal at all, which
/// keeps `uids` the same length and the same index space as `shapes`.
///
/// Takes `[Shape?]` so the vertex table's `[Shape]` promotes on the way in.
private static func uids(for shapes: [Shape?], in graph: BRepGraph?) -> [BRepGraph.GraphUID?]? {
guard let graph else { return nil }
return shapes.map { sub in
guard let node = graph.findNode(for: sub) else { return nil }
guard let sub, let node = graph.findNode(for: sub) else { return nil }
return graph.uid(ofNodeKind: Int(node.kind.rawValue), index: node.index)
}
}
Expand Down
6 changes: 6 additions & 0 deletions Sources/OCCTSwiftTools/VertexIdentityTable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ public struct VertexIdentityTable: Sendable {
/// Built from `Shape.subShapes(ofType: .vertex)`, the same `TopTools_IndexedMapOfShape`
/// traversal `Shape.vertices()` / `Shape.vertex(at:)` use, so `shapes[ordinal]` is always the
/// exact vertex behind the pick point carrying that ordinal.
///
/// `[Shape]` rather than the `[Shape?]` its two siblings hold, and deliberately so: that
/// traversal returns `Shape` values directly, with no failable `Face`/`Edge` conversion in the
/// way, so there is no step here that could drop an element and shift every later ordinal
/// (OCCTSwiftInteraction#9). The asymmetry records that the hazard is the conversion, not the
/// enumeration.
public let shapes: [Shape]

/// Durable per-ordinal handle, minted from the `BRepGraph` supplied when the table was built.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@ struct InteractiveContextMutationTests {
// Locate the shared face by identity, not by position: an ordinal is an
// artifact of enumeration order, `isSame` is the decision's own primitive
// and is what makes this assertion survive a reordering of the fixture.
let sharedOrdinal = try #require(identity.shapes.firstIndex { $0.isSame(as: sharedFace) })
let sharedOrdinal = try #require(
identity.shapes.firstIndex { $0?.isSame(as: sharedFace) == true })
let sharedUID = try #require(identity.uid(forOrdinal: sharedOrdinal))
// Sanity: everything below is non-vacuous, no OTHER face carries that uid.
for ordinal in identity.shapes.indices where ordinal != sharedOrdinal {
Expand Down
9 changes: 5 additions & 4 deletions Tests/OCCTSwiftToolsTests/EdgeIdentityTableTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,11 @@ struct EdgeIdentityTableTests {
}
let expectedUID = graph.uid(ofNodeKind: Int(kind.rawValue), index: index)
guard
let ordinal = (0..<edgeTable.shapes.count).first(where: {
graph.findNode(for: edgeTable.shapes[$0]).map {
$0.kind == kind && $0.index == index
} ?? false
let ordinal = (0..<edgeTable.shapes.count).first(where: { candidate in
guard let edgeShape = edgeTable.shape(forOrdinal: candidate),
let node = graph.findNode(for: edgeShape)
else { return false }
return node.kind == kind && node.index == index
})
else {
Issue.record("no table ordinal resolves to the shared edge's graph node")
Expand Down
Loading
Loading