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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

A comprehensive Swift wrapper for [OpenCASCADE Technology (OCCT)](https://www.opencascade.com/) 8.0.0p1, providing B-Rep solid modeling for macOS and iOS. **v1.0.0 — SemVer-stable as of 2026-05-07.**

**4,304 wrapped operations** | macOS 12+ / iOS 15+ / visionOS 1+ / tvOS 15+ (arm64) | OCCT 8.0.0p1
**4,306 wrapped operations** | macOS 12+ / iOS 15+ / visionOS 1+ / tvOS 15+ (arm64) | OCCT 8.0.0p1

## Quick Start

Expand Down
4 changes: 2 additions & 2 deletions Scripts/repro/censuses/ClusterB.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ enum ClusterB {
let firstOnlyVolume = box.blendedEdges([(0, 2.0)])?.volume
let dupVerdict: String
if volumesAgree(dupVolume, lastOnlyVolume) && !volumesAgree(dupVolume, firstOnlyVolume) {
dupVerdict = "OVERWRITE: last radius wins (dup \(fmt(dupVolume)) == last-only \(fmt(lastOnlyVolume)), != first-only \(fmt(firstOnlyVolume))) -- #633"
dupVerdict = "OVERWRITE: last radius wins (dup \(fmt(dupVolume)) == last-only \(fmt(lastOnlyVolume)), != first-only \(fmt(firstOnlyVolume))) -- #633 fixed: blendedEdgesWithReport(_:) sibling reports overwrittenDuplicateIndices"
} else if volumesAgree(dupVolume, firstOnlyVolume) {
dupVerdict = "OVERWRITE: first radius wins (dup \(fmt(dupVolume)) == first-only \(fmt(firstOnlyVolume)))"
} else {
Expand All @@ -202,7 +202,7 @@ enum ClusterB {
record("fillet-edges (per-edge radius)", "blendedEdges(_:)",
duplicate: dupVerdict,
outOfRange: outOfRange == nil ? "REJECT (nil)" : "measured non-nil",
declined: declined != nil ? "SKIP (non-nil, area \(fmt(declined?.surfaceArea)) vs unfilleted \(fmt(shellPlainArea)))" : "REJECT (nil)",
declined: declined != nil ? "SKIP (non-nil, area \(fmt(declined?.surfaceArea)) vs unfilleted \(fmt(shellPlainArea))) -- #633 fixed: blendedEdgesWithReport(_:) sibling also reports declinedEdgeIndices, adopting #639's mechanism" : "REJECT (nil)",
empty: box.blendedEdges([]) == nil ? "REJECT (nil)" : "non-nil",
note: "compound out-of-range: \(compoundOutOfRange == nil ? "REJECT (nil), matches single-solid" : "non-nil")")
}
Expand Down
22 changes: 15 additions & 7 deletions Scripts/repro/cluster-b-fillet-edge-contract/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ python3 Scripts/repro/cluster-b-fillet-edge-contract/classify_fillet_sites.py --
|---|---|---|---|---|---|
| fillet-edges | `filleted(edges:radius:)` | N/A: uniform radius (dup 991.415927 == single 991.415927) | REJECT (nil) | SKIP (non-nil, area 465.097336 vs unfilleted 500.000000) -- **#639 fixed**: `filletedWithReport(edges:radius:)` sibling reports `declinedEdgeIndices` | REJECT (nil) |
| fillet-linear | `filleted(edges:startRadius:endRadius:)` | N/A: uniform law (dup 990.523733 == single 990.523733) | REJECT (nil) | SKIP (non-nil, area 465.097336 vs unfilleted 500.000000) -- **#639 fixed**: `filletedWithReport(edges:startRadius:endRadius:)` sibling reports `declinedEdgeIndices` | REJECT (nil) |
| fillet-edges (per-edge radius) | `blendedEdges(_:)` | **OVERWRITE: last radius wins** (dup 946.349541 == last-only 946.349541, != first-only 991.415927) -- #633 | REJECT (nil) | SKIP (non-nil, area 465.097336 vs unfilleted 500.000000) | REJECT (nil) |
| fillet-edges (per-edge radius) | `blendedEdges(_:)` | **OVERWRITE: last radius wins** (dup 946.349541 == last-only 946.349541, != first-only 991.415927) -- **#633 fixed**: `blendedEdgesWithReport(_:)` sibling reports `overwrittenDuplicateIndices` | REJECT (nil) | SKIP (non-nil, area 465.097336 vs unfilleted 500.000000) -- **#633 fixed**: the same sibling also reports `declinedEdgeIndices`, adopting #639's mechanism | REJECT (nil) |
| fillet-variable | `filletedVariable(edgeIndex:radiusProfile:)` | N/A: scalar edgeIndex, no list | REJECT (nil) | REJECT (nil): single edge, no partial fillet possible | N/A: no list; `radiusProfile` needs >=2 points, stricter than `filletEvolving`'s >=1 |
| fillet-evolving | `filletEvolving(_:)` | **OVERWRITE: last law wins** (dup 946.349541 == last-only 946.349541), documented on `EvolvingFilletEdge`, same mechanism as #633 | REJECT (nil) | SKIP (non-nil, area 465.097336) -- **#639 fixed**: `filletEvolvingWithReport(_:)` now reports `declinedEdgeIndices` | REJECT (nil) |
| fillet-edges (with history) | `filletedWithFullHistory(radius:edges:)` | N/A: uniform radius (dup 997.853982 == single-edge 997.853982) | REJECT (nil) | SKIP (non-nil, area 465.097336) -- **#639**: needed no new API; `history.record(of:)`'s `!isDeleted && generated.isEmpty` already names the declined set | REJECT (nil) |
Expand Down Expand Up @@ -93,12 +93,20 @@ loop at all, reproduce the identical direction per family. The behaviour lives i
`BRepFilletAPI_MakeFillet::Add`/`BRepFilletAPI_MakeChamfer::Add` themselves, not in anything this
bridge's own loops do.

**#633 asks for one contract, chosen and applied across the whole family.** This measurement says
the fillet side is internally consistent (three independent entry points, plus the class API, all
last-wins) and the chamfer side is *also* internally consistent (two entry points plus its own
class API, all first-wins) -- so unifying the two families onto one contract is a bigger decision
than #633's own title suggests, since it means picking a *direction* and changing it for one whole
family, not just deciding to reject/dedupe/document.
**#633 asked for one contract, chosen and applied across the whole family; what actually landed is
report, not converge -- the same decision #639 made for the OCCT-declined axis of this same grid.**
Changing what `blendedEdges(_:)` returns for an input it already accepts would be a behaviour
change on every existing caller who happens to name an edge twice, for a benefit (picking a
"winning" direction) no caller asked for. `blendedEdgesWithReport(_:)` reports which entries a
duplicate overwrote (`Shape.FilletResult.overwrittenDuplicateIndices`, extending the same struct
#639 introduced rather than adding a second reporting shape), and `blendedEdges(_:)` itself is
byte-for-byte unchanged. **This measurement's own finding stands, unrevised**: the fillet side is
internally consistent (three independent entry points, plus the class API, all last-wins) and the
chamfer side is *also* internally consistent (two entry points plus its own class API, all
first-wins) -- unifying the two families onto one contract is still a bigger decision than #633's
own title suggested, since it means picking a *direction* and changing it for one whole family, not
just deciding to reject/dedupe/document. That decision remains open; #633 closes on observability
alone, matching #639.

## New findings (not in #520/#568/#612/#633/#639)

Expand Down
15 changes: 14 additions & 1 deletion Sources/OCCTBridge/include/OCCTBridge_Healing.h
Original file line number Diff line number Diff line change
Expand Up @@ -137,14 +137,27 @@ OCCTWireRef OCCTWireChamferAll2D(OCCTWireRef wire, double distance);
/// OCCTShapeFilletEdges and OCCTShapeFilletEdgesLinear. Implemented in OCCTBridge_Healing.mm
/// while the other two are in OCCTBridge_Modeling.mm, which is how it came to be the one without
/// a radius precondition. #489
///
/// #633: `declinedEdgeIndices`/`outDeclinedCount` report which of `edgeIndices` OCCT declined,
/// same contract #639 gave OCCTShapeFilletEdges/OCCTShapeFilletEdgesLinear/OCCTShapeFilletEvolving.
/// Both are nullable and the existing skip behaviour is unchanged when they are null;
/// `blendedEdges(_:)` passes null for both, `blendedEdgesWithReport(_:)` does not. The *other* axis
/// this function's caller has to report -- the same edge index named twice, which silently
/// overwrites one radius with another at the shared fillet slot -- needs no OCCT round trip at all,
/// since it is a property of `edgeIndices` itself; it is computed Swift-side.
/// @param shape The shape to blend
/// @param edgeIndices Array of edge indices (0-based; an index naming no edge of `shape` rejects
/// the whole call, #520)
/// @param radii Array of radii (one per edge); every element must be > 0, or the whole call fails
/// @param count Number of edges
/// @param declinedEdgeIndices Optional (may be NULL): buffer of at least `count` int32s to receive
/// the 0-based indices of requested edges OCCT declined to fillet, in `edgeIndices`' own order.
/// Same contract as OCCTShapeFilletEdges.
/// @param outDeclinedCount Optional (may be NULL): same contract as OCCTShapeFilletEdges.
/// @return Blended shape, or NULL on failure
OCCTShapeRef OCCTShapeBlendEdges(OCCTShapeRef shape,
const int32_t* edgeIndices, const double* radii, int32_t count);
const int32_t* edgeIndices, const double* radii, int32_t count,
int32_t* declinedEdgeIndices, int32_t* outDeclinedCount);

/// Parameters for surface filling operation
///
Expand Down
10 changes: 8 additions & 2 deletions Sources/OCCTBridge/src/OCCTBridge_Healing.mm
Original file line number Diff line number Diff line change
Expand Up @@ -714,16 +714,22 @@ OCCTWireRef OCCTWireChamferAll2D(OCCTWireRef wire, double distance) {
// Shares occtShapeFilletEdgeList (OCCTBridge_Internal.h) with OCCTShapeFilletEdges and
// OCCTShapeFilletEdgesLinear in OCCTBridge_Modeling.mm, supplying only the per-edge radius.
// This is the entry point that had no radius precondition at all; see that helper. #489
//
// #633: `declinedEdgeIndices`/`outDeclinedCount` report which of `edgeIndices` OCCT declined
// (occtFilletWriteDeclined), the same contract #639 gave the other three edge-list entry points.
// Both are nullable and the existing skip behaviour is unchanged when they are null.
OCCTShapeRef OCCTShapeBlendEdges(OCCTShapeRef shape,
const int32_t* edgeIndices, const double* radii, int32_t count) {
const int32_t* edgeIndices, const double* radii, int32_t count,
int32_t* declinedEdgeIndices, int32_t* outDeclinedCount) {
if (outDeclinedCount) *outDeclinedCount = 0;
if (!occtValidFilletRadii(radii, count)) return nullptr;

return occtShapeFilletEdgeList(shape, edgeIndices, count,
[radii](BRepFilletAPI_MakeFillet& fillet,
const TopoDS_Edge& edge, int32_t entry) {
fillet.Add(radii[entry], edge);
return true;
});
}, declinedEdgeIndices, outDeclinedCount);
}

// MARK: - Surface filling (#430/#434)
Expand Down
104 changes: 103 additions & 1 deletion Sources/OCCTSwift/Shape+Modeling.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,34 @@ extension Shape {
/// edge twice reports it twice, so the count matches how many entries of the caller's list
/// were refused. Use `Set(declinedEdgeIndices)` for distinct edges.
public let declinedEdgeIndices: [Int]
/// 0-based edge indices, matching ``Edge/index``, whose radius a *later* entry in the same
/// request overwrote (#633). Empty for every `WithReport` sibling except
/// ``Shape/blendedEdgesWithReport(_:)``, the one entry point that takes a per-edge radius
/// array where naming an edge twice is even possible.
///
/// `BRepFilletAPI_MakeFillet::Add(radius, edge)` resolves the edge's own slot within its
/// contour and writes there, so a second `Add` on the same edge silently replaces the first
/// radius rather than erroring or combining the two. This mirrors the request list, the
/// same convention as `declinedEdgeIndices`: an edge index requested three times reports
/// two overwritten entries here (the two radii that lost), not one. Use
/// `Set(overwrittenDuplicateIndices)` for distinct edges.
public let overwrittenDuplicateIndices: [Int]

/// Explicit rather than the synthesized memberwise init, and the reason is this method,
/// not the three that came before it. A `let` property with a default value is dropped from
/// Swift's synthesized memberwise init entirely: it is not an overridable parameter the way
/// a `var` with a default is, so a caller **cannot pass it at all**.
///
/// The three existing `WithReport` call sites would have compiled unchanged against the
/// synthesized init, silently taking the `[]` default, since none of them has anything to
/// say about a duplicate index. `blendedEdgesWithReport(_:)` is the first caller that needs
/// to pass a **non-default** value, and that is what the synthesized init structurally
/// cannot express.
public init(shape: Shape, declinedEdgeIndices: [Int], overwrittenDuplicateIndices: [Int] = []) {
self.shape = shape
self.declinedEdgeIndices = declinedEdgeIndices
self.overwrittenDuplicateIndices = overwrittenDuplicateIndices
}
}

/// Fillet specific edges with uniform radius
Expand Down Expand Up @@ -740,6 +768,12 @@ extension Shape {
/// this shape, on the same all-or-nothing basis: one that does not rejects the batch rather
/// than being skipped, so a result is never a partial fillet reported as a complete one.
///
/// The same edge index named twice is **not** rejected and does not combine the two radii: OCCT
/// writes both `Add` calls to that edge's own slot within its fillet contour, so the *second*
/// silently overwrites the first (#633). This is unchanged, existing behaviour, documented here
/// because it used to be undocumented and silent; use ``blendedEdgesWithReport(_:)`` for the
/// same fillet with a report naming which entries a duplicate overwrote.
///
/// - Parameter edgeRadii: Array of (0-based edgeIndex, radius) pairs; each radius must be > 0
/// - Returns: Filleted shape, or nil on failure, including an empty array, a non-positive
/// radius anywhere in the array, or an index that names no edge of this shape
Expand Down Expand Up @@ -770,12 +804,80 @@ extension Shape {
handle,
&indices,
&radii,
Int32(edgeRadii.count)
Int32(edgeRadii.count),
nil,
nil
) else {
return nil
}
return Shape(handle: result)
}

/// ``blendedEdges(_:)``, also reporting which requested edges OCCT declined to fillet, and
/// which duplicate entries were silently overwritten (#633).
///
/// `blendedEdges(_:)` does not deduplicate `edgeRadii`: naming the same edge index twice writes
/// the same fillet slot twice, and only the *last* radius written survives -- the earlier one is
/// discarded with no signal, the same shape of silent-wrong-answer #639 found on the declined-edge
/// axis of this family. That existing behaviour is unchanged here; this method only adds a way
/// to observe it, following #639's recommendation to extend ``FilletResult`` rather than invent
/// a second reporting shape for the same idea.
///
/// ```swift
/// let box = Shape.box(width: 10, height: 10, depth: 10)!
/// if let report = box.blendedEdgesWithReport([(0, 2.0), (0, 5.0)]) {
/// print(report.overwrittenDuplicateIndices) // [0]: edge 0's first radius (2.0) lost
/// print(report.declinedEdgeIndices) // []: every edge of a closed box fillets
/// }
/// ```
///
/// - Parameter edgeRadii: Array of (0-based edgeIndex, radius) pairs; each radius must be > 0
/// - Returns: A ``FilletResult``, or nil on failure under the same conditions as
/// ``blendedEdges(_:)``.
public func blendedEdgesWithReport(_ edgeRadii: [(edgeIndex: Int, radius: Double)]) -> FilletResult? {
guard !edgeRadii.isEmpty, edgeRadii.allSatisfy({ $0.radius > 0 }) else { return nil }

var indices = edgeRadii.map { Int32($0.edgeIndex) }
var radii = edgeRadii.map { $0.radius }

var declined = [Int32](repeating: 0, count: edgeRadii.count)
var declinedCount: Int32 = 0
let result: OCCTShapeRef? = declined.withUnsafeMutableBufferPointer { declinedBuffer in
OCCTShapeBlendEdges(
handle,
&indices,
&radii,
Int32(edgeRadii.count),
declinedBuffer.baseAddress,
&declinedCount
)
}
guard let result else { return nil }
let declinedIndices = declined.prefix(Int(declinedCount)).map { Int($0) }
return FilletResult(shape: Shape(handle: result), declinedEdgeIndices: declinedIndices,
overwrittenDuplicateIndices: Shape.overwrittenDuplicateIndices(in: edgeRadii))
}

/// 0-based edge indices from `edgeRadii` that a later entry in the same request overwrote.
///
/// This is a property of `edgeRadii` itself: `edgeIndex` maps to a unique edge via `Shape`'s own
/// `TopExp` enumeration, so two entries naming the same numeric index always name the same edge,
/// and no OCCT round trip is needed to tell which entries lost. Mirrors ``FilletResult`` /
/// `declinedEdgeIndices`'s own convention: every overwritten *entry* is reported, not just the
/// distinct edges, so `[(0, 1.0), (0, 2.0), (0, 3.0)]` reports `[0, 0]` -- two entries lost, one
/// (the last) won -- not `[0]`.
private static func overwrittenDuplicateIndices(in edgeRadii: [(edgeIndex: Int, radius: Double)]) -> [Int] {
var lastPosition: [Int: Int] = [:]
for (position, pair) in edgeRadii.enumerated() {
lastPosition[pair.edgeIndex] = position
}
var overwritten: [Int] = []
for (position, pair) in edgeRadii.enumerated() where lastPosition[pair.edgeIndex] != position {
overwritten.append(pair.edgeIndex)
}
return overwritten
}

/// Create a wedge (tapered box).
///
/// A wedge is a box whose top face is narrowed in the X direction.
Expand Down
Loading