Link Search Menu Expand Document

Changelog

All notable changes to OCCTSwift.

Current: v3.0.0

macOS / iOS (device + simulator) | OCCT 8.0.1 (+ the seventeen carried patches 0010-0012 and 0014-0027; ten earlier patches were absorbed by 8.0.1 itself and retired)

A correctness and consolidation release. The kernel does not move, but it is rebuilt: the v2.0.0 asset was missing two carried patches (#905, #913) that no CI job exercised. Two breaking changes, each named with its migration in SEMVER.md, an enum rename (#844), and six bounding-box accessors becoming Optional so a void shape stops fabricating (0,0,0)-(0,0,0) (#943).


Unreleased

Documentation: the STEP/IGES bridge surface is already serialized (#342)

OCCTSerialQueue and Exporter now state that writeSTEP, writeIGES, Shape.load(from:) and Document.loadSTEP are serialized inside the bridge, so they neither need OCCTSerial.withLock nor gain concurrency from it. OCCTSerialQueue previously said the opposite, which is the file a caller reads before deciding. The mesh and BREP writers carry no such lock and the deep-copy advice still applies to them.

The pinned kernel carries all twenty-nine patches (#1408)

Package.swift pins v4.0.0-kernel.1, which is OCCT V8_0_1 plus every carried patch. Twelve had been on disk and in no CI job, because build-and-test resolves the pinned asset rather than building from source, so they reached no consumer: an uncatchable SIGSEGV through Document.datums (0029), data races on TopoDS_TShape::myState and the B-spline caches (0030, 0031), a wrong surface from Shape.coonsAlgPatch (0034), and the #1403 data-exchange series (0036-0041), which took named racing globals from 16 to 0. Scripts/tsan.supp loses the TopoDS_TShape::myState suppressions, which were hiding a race the kernel now fixes.

Restated enum case lists are checked against their declaration (#2145)

check-docs-defaults.py now compares the case list a docs/reference/ page restates against the enum it documents, resolving same-named enums by the page’s heading chain. Previously nothing checked a restatement at all: the doc-snippet census skips all 5,092 of them because a bodiless declaration compiles in no context, which is how Drawing.md came to declare case A0, A1, A2, A3, A4 against a source reading case a0, a1, a2, a3, a4 and seed seven wrong examples. Construction.md was missing three MaterializationFailure cases and now lists all nine.

Documentation snippets are now gated, not censused (#1683)

census-doc-snippets.py is renamed check-doc-snippets.py and a non-compiling snippet fails the build. It reported rather than gated while a 211-snippet backlog stood, since a required check red for every PR is worse than no check; #2092 reclassified 24 reference pages that were eliding content the reader supplies, #2093 fixed the remaining 187, and the gate was promoted at zero. 3,105 snippets: 1,661 compile and 1,444 are fragments that open mid-flow and are not failures.

A gate on the bridge’s caught-exception diagnostics (#2077)

Scripts/check-bridge-diagnostics.py fails when a function-level catch (...) block in Sources/OCCTBridge/src/*.mm does not call occtRecordCaughtException(__func__); as its first statement, unless the site is on an exemption list carrying a written reason. It runs in ci.yml’s gate-scripts job and in the optional pre-commit hook, the twelfth gate in that job, and it is what stops a newly written bridge function returning a nil that explains nothing.

Two sites are exempt, both the diagnostics channel’s own internals, where a record would feed itself: occtRecordCaughtException’s classification ladder, where a call throw;s the same exception back into its own clause and recurses until the stack runs out, and occtDiagnosticsLog, its tail.

With the gate in place, the coverage caveat that shipped with #1161 is gone from docs/reference/Diagnostics.md, OCCTBridge.h and OCCTDiagnostics’ doc comment. An empty capture now means the failure raised nothing to classify (IsDone() == false, a null result handle, a rejected argument), not that the site was never instrumented. Diagnostics.md gains a section on deeper catch blocks: 21 of the bridge’s 54 record, and 33 say in place why they do not.

Caught-exception diagnostics reach the whole bridge (#2077)

OCCTDiagnostics now reports the OCCT exception behind a refused call from any bridge file. This PR finishes the sweep #1161 started: 1,470 function-level catch (...) blocks across the remaining 30 files, which is OCAF documents, the BRep graph, visualisation and AIS, meshing, the spatial and math solvers, ProjLib/NLPlate, HLR, eight more Modeling files and OCCTBridge.mm itself. Coverage reaches 3,597 of the bridge’s 3,599 function-level catch blocks.

The two that do not record are the channel’s own internals, and both would feed themselves: occtRecordCaughtException’s classification ladder would throw; the same exception into its own clause and recurse until the stack ran out, and occtDiagnosticsLog is that function’s tail, where a diagnostic that throws must not become the failure being diagnosed. Both carry the reason in place.

Twelve deeper blocks stay uninstrumented, all recover-and-continue and all with the reason in place: occtSampleWirePoints in the eight remaining Modeling files, two per-solution skips in the IntAna intersection results, OCCTShapeGetEdgeMesh’s per-edge skip, and occtHasSelfIntersectingWire’s per-wire skip.

Caught-exception diagnostics reach surfaces, curves and 2D geometry (#2077)

OCCTDiagnostics now reports the OCCT exception behind a refused surface, 3D curve or 2D curve operation. 1,045 function-level catch (...) blocks across the seven OCCTBridge_Surface_*.mm, six OCCTBridge_Curve3D_*.mm and seven OCCTBridge_Geom2d_*.mm files hand the Standard_Failure’s type name and message to the channel #1161 added instead of discarding them. This is the largest of the sweep’s domains and takes bridge coverage past half.

Six deeper blocks are instrumented too, all of them OCCTBSplineApproxInterp::run()’s catch, once per Curve3D translation unit. It neither rethrows nor recovers: it turns the exception into done = false, which every caller reports as a refused fit, and no enclosing handler ever runs. It records under the explicit context OCCTBSplineApproxInterp::run, because __func__ inside that member function reads only run.

Ten deeper blocks stay uninstrumented with the reason in place, all recover-and-continue: the boundary-curve distance loop’s per-curve skip in each Geom2d file, the two medial-axis fallbacks to node-position interpolation, and occtSignedGeom2dCurvesArea’s per-sample skip.

Caught-exception diagnostics reach topology and measurement (#2077)

OCCTDiagnostics now reports the OCCT exception behind a refused topology query, adjacency walk, bounding box, extrema search or mass/length/area measurement. 333 function-level catch (...) blocks across the five OCCTBridge_Topology_*.mm files and OCCTBridge_Properties.mm hand the Standard_Failure’s type name and message to the channel #1161 added instead of discarding them.

Fifteen deeper blocks are instrumented too, all of them occtFindSurface’s three inner catch (...) clauses, once per Topology translation unit. Those neither rethrow nor recover: each turns the exception into the refusal the caller sees (found == false, toleranceReached == -1.0, existed == false) without the outer handler ever running, so recording there is the only place the reason exists. A note at each site says so.

Three deeper blocks stay uninstrumented with the reason in place, all recover-and-continue: OCCTBRepExtremaExtCCEdges’ perpendicular-foot count, OCCTShapeOuterShells’ per-solid skip, and the axis-collection walk’s per-face skip.

Caught-exception diagnostics reach data exchange (#2077)

OCCTDiagnostics now reports the OCCT exception behind a refused STEP, IGES, BREP, mesh or OSD operation. 192 function-level catch (...) blocks across the seven OCCTBridge_IO_*.mm files hand the Standard_Failure’s type name and message to the channel #1161 added instead of discarding them.

This sits alongside OCCTReturnStatus (#1644) rather than replacing it: the return status is what the translator concluded and is always present, while a diagnostic record appears only when the kernel actually raised. A failed read with a status and no record raised no exception.

The three GeomTools_*Set counting loops in OCCTBridge_IO_NativeFormats.mm keep their inner catch (...) uninstrumented, with the reason in place: those sets are 1-based with no count accessor, so the catch is how the loop finds the end of the collection on a well-formed file, not a failure.

Caught-exception diagnostics reach shape healing (#2077)

OCCTDiagnostics now reports the OCCT exception behind a refused heal, fix, upgrade, sewing, blend or filling. 266 function-level catch (...) blocks across the seven OCCTBridge_Healing_*.mm files hand the Standard_Failure’s type name and message to the channel #1161 added instead of discarding them, so a nil from ShapeFix-backed API can now name the OCCT exception the kernel raised rather than only that something refused.

OCCTShapeAnalyze’s per-shell orientation scan keeps its inner catch (...) uninstrumented, with the reason in place: it skips one unusable shell and the analysis goes on to return a full result, so recording it would report a failure for a call that did not fail.

Caught-exception diagnostics reach sweep, fillet and chamfer (#2077)

OCCTDiagnostics now reports the OCCT exception behind a refused draft, sweep, loft, fillet or chamfer. 189 function-level catch (...) blocks in OCCTBridge_Modeling_Sweep.mm, OCCTBridge_Modeling_Fillet.mm and OCCTBridge_Modeling_Chamfer.mm hand the Standard_Failure’s type name and message to the channel #1161 added instead of discarding them, so Shape.draft(wire:direction:angle:length:) returning nil on a zero direction now says Standard_ConstructionError: gp_Dir() - input vector has zero norm rather than nothing at all.

Coverage is still partial while #2077’s sweep runs, and a capture that comes back empty still means “no instrumented site reported” rather than “nothing was caught”. Derive it from the source:

grep -rc occtRecordCaughtException Sources/OCCTBridge/src

Each file’s one deeper catch (...), the shared occtSampleWirePoints block, is deliberately left out with the reason in place: it recovers, so recording it would report a failure for a call that went on to succeed.

Guide and architecture snippets now compile (#2093)

Eleven fenced examples in docs/architecture/overview.md, docs/guides/occt-concepts.md, and the /// comments of BRepGraph.swift, Exporter.swift, FeatureRecognition.swift, Mesh.swift and Selection.swift, all of them using a failable factory’s result without unwrapping it. The guide snippets are the longest in the docs and the optionals come in layers: occt-concepts.md’s rail example needed six, ending at Shape’s - operator, which returns Shape? and so cannot chain as a - b - c. Also corrected: ShapeMeasurements.faceCentroids is [SIMD3<Double>?] and Edge.bounds is (min:max:)?, which three comparison closures had been reading through as if they were not.

Drawing, vector-export and sheet-metal snippets now compile (#2093)

Fourteen fenced examples on Drawing.md, Export-Vector.md, SheetMetal.md and in Drawing.swift doc comments. Seven said PaperSize.A3 or .A4, whose real cases are .a3 and .a4: that is a rename that reached Sources/ before the #1103 merge and never reached the page, and it survived because the signature restatement at Drawing.md:886 still declared the uppercase form, in the one fence population the snippet census skips by design. Also corrected: writePDF/writeSVG written with a trailing closure, which their sheet:body:to:deflection: order makes impossible; DrawingScale.oneToTwo, which is .reduction(2); DXFWriter.dxfString(), which is write(to:); and DrawingDimension.Linear(value:), where value is a computed property rather than an init parameter.

Document reference snippets now compile (#2093)

Thirty-eight fenced examples across the eleven Document-* reference pages and Construction.md. Nineteen were PipeShellBuilder examples whose only statement was pipe.<member>(...): pipe is declared nowhere in those fences and also names a libc function, so every one resolved to (UnsafeMutablePointer<Int32>?) -> Int32 and failed. Each now constructs its receiver. Also corrected: Shape.edgeFromLine(from:to:), which is edgeFromLine(origin:direction:p1:p2:); Wire.asShape(), which is Shape.fromWire(_:); Document(), whose initializer is internal, so the factory is Document.create(); Document.isValid, which does not exist; TrigRoots.solve(B:), whose sin(x) coefficient is b:; and Curve3D.line/Surface.plane scalar-component spellings that have never existed.

Curve3D and Curve2D reference snippets now compile (#2093)

Thirty-seven fenced examples across Curve2D-Analytic-Types.md, Curve2D-Constraint-Solvers.md, Curve3D-Analysis.md, Curve3D-Analytic-Types.md, CurveAdaptors.md, Geometry2D.md and Shape-Recognition.md, and in Curve2D.swift, Curve3D.swift and Continuity.swift doc comments, named members and labels that have never existed. Curve3D.line(origin:direction:) is line(through:direction:); parabola(vertex:...) is parabola(center:...); curveKind is curveType, an Int where 1 is Circle, so the examples no longer claim to print .circle; Curve2D.parameterRange is domain, which is not optional, so the force-unwraps go too. Curve2D.ellipse, hyperbola and parabola all take a placement the examples omitted, and Curve2D.bspline(points:) had been resolving to the instance accessor of the same name.

Surface reference snippets now compile (#2093)

Forty-four fenced examples on Surface.md, Surface-Analysis.md, Surface-Analytic-Types.md and Surface-Advanced.md, and in Surface.swift and Shape+Surface.swift doc comments, called Surface factories with argument lists that have never existed. An analytic surface is unbounded, so Surface.cylinder(radius: 10, height: 50) was never a call: the placement is cylinder(origin:axis:radius:). Also corrected: sphere(center:radius:), cone(origin:axis:radius:semiAngle:), torus(origin:axis:majorRadius:minorRadius:), plane(origin:normal:), and extrusion(profile:direction:), whose profile is a Curve3D rather than a Wire. Surface.md’s continuityClass example now uses a real B-spline patch and reports a measured .cN instead of a guessed .c2.

A misnamed carried patch is reported, not a crash (#2148)

check-inventory-prose.py read the leading NNNN off every .patch in Scripts/patches/, so one file that was not NNNN-named raised ValueError: invalid literal for int() with base 10: 'wasi' and took all twenty-one of the gate’s claims down with it. The odd file is now reported, with the directory a patch for another build target belongs in.

The doc-snippet census stops passing on a population it never examined (#2098, #2092)

census-doc-snippets.py type-checked 24 of 3,105 snippets in CI while the step passed. The built module’s path was guessed from a list of layouts SwiftPM no longer uses, and the guess missed on the one machine that matters. Two things were wrong: the module sits under Modules/ in CI, and it is a plain file there rather than the directory bundle a local build produces. The path is now searched for rather than guessed, and --require-typecheck fails the step instead of reporting on a population it never examined. CI’s self-test went from 27 cases to 56, so every compile case, including the canary cases that exist to catch a silent compiler, now runs where it counts.

Separately, reference pages elide content the reader is expected to supply (= ..., { ... }, [...], = // prose), which does not parse and is not a documentation defect. Those 24 snippets were reported as failures; they are fragments. Reclassifying them left exactly one real finding the rule refused to excuse, a Package.swift manifest fragment fenced as swift, now exempt with a written reason. unparseable is zero.

Shape, Edge, Face and Wire reference snippets now compile (#2093)

Forty-one fenced examples across Shape-Features.md, Shape-Completions.md, Shape-Builders-1.md, Annotation.md, Edge.md, Face.md and Wire.md, and in Shape.swift, Shape+Topology.swift, MedialAxis.swift, Edge.swift, Wire.swift and WireOrder.swift doc comments. Shape.box(dx:dy:dz:) is box(width:height:depth:). Wire.asShape, Face.shape and Edge.shape never existed: the conversions are Shape.fromWire(_:), fromFace(_:) and fromEdge(_:). Wire has no translated(by:) at all, so the profile examples now place the circle at construction with Wire.circle(origin:normal:radius:). Shape.makeFace and Shape.makePolygon are Shape.face(from:) with Wire.polygon3D(_:closed:); Edge.line(from:to:) is Wire.line(from:to:)!.edges()[0]; SurfaceContinuity has no .c0; Edge.adjacentFaces(in:) returns an array, not a pair; and the ≈ operator one example used is defined nowhere in the package.

The bridge header split gate validated its own view, not just its verdict (#2080)

Scripts/derive-bridge-header-split.py stripped C comments in two passes, block comments first, so any // comment containing the text /* opened a block comment that was never closed. In OCCTBridge.h a comment mentioning Sources/OCCTBridge/src/*.mm ran to the file’s own #endif /* OCCTBridge_h */, hiding 14 of its 16 declarations, and the gate printed misfiled: 0 throughout. Its --self-test passed as well, because no fixture carried the trigger, and comments naming a glob are ordinary here.

Both halves are fixed. Comments are now stripped in one pass, so the opener that actually opened first is the one in effect, and string and character literals are skipped intact because either can hide an opener. Separately the script now refuses to report at all when a header the split owns yields no declaration, which is the observable form of a swallowed file and would have caught this without anyone guessing the trigger.

The general rule is written up in okf/policies/static-gates.md: a detector must assert on the real run that its own view is plausible, because a self-test only proves it catches the failure modes its author thought of. Three detectors were caught in one day with passing self-tests, and #618, #624/#630 and #626 are the same shape years earlier.

Every fenced Swift snippet in the docs is now type-checked (#1683)

Scripts/census-doc-snippets.py hands every fenced swift block in docs/ and in every /// doc comment to swiftc, which is the first thing to read inside those fences at all. docs-current.md has always asked for a runnable snippet on every documented API and nothing checked that any of them compiled, which is how Curve3D.arc(center:radius:startAngle:endAngle:), a factory that has never existed, reached 18 call sites (#1675). It compiles rather than matching argument labels with a regex: #1675 holds two attempts at the regex, and each reported a real API as missing.

Of 8,181 fences, 5,082 are signature restatements that a bodiless func makes uncompilable in any context, and 3,096 are snippets. Of those, 1,470 compile, 1,415 open mid-flow on a name the surrounding prose introduced, and 211 do not compile. A census rather than a gate for that reason alone: --strict exits 1, and promotion is that flag becoming the default plus a rename, once the backlog is zero. It runs in swift build + test (macOS) rather than gate-scripts, which is pure Python with no build.

Corrected here: the 17-site Curve3D.line(from:to:) cluster, which is segment(from:to:), across Curve3D-Analysis.md, Surface.md, Surface-Analysis.md and Document-Geometry-Constructors.md. A snippet that is deliberately not compilable now carries its exemption on the page, as no-typecheck: <reason> in the fence info string, with the reason required.

Carried patches 0040 and 0041: the data-exchange globals reach zero (#1403)

0040, three unguarded one-time-init flags. Both STEPControl_Controller::Init() and IGESControl_Controller::Init(), plus the IGES constructor, used a check-then-act static bool that two threads could both pass, running the one-time setup twice and recording a second controller under the same name. #1403’s own re-scope described this as “matching STEP’s existing mutex”; that was wrong, since only STEP’s constructor had one. All three become function-local static initialisation, which removes the check-then-act rather than locking around it.

0041, two shared registries. listad (controllers by format name) and atemp (template models by name) are process-wide NCollection_DataMaps mutated without synchronisation. Unlike the rest of this series these are legitimately one-per-process, so a recursive mutex is the right tool rather than relocating ownership. Recursive is required, not preferred: Interface_InterfaceModel::Template calls HasTemplate before reading the map.

Measured by rebuilding the ThreadSanitizer kernel and re-running the five registered data-exchange scenarios. Every named racing global is now gone:

  Reports Named globals
Before 0036 178 16
After 0036-0039 96 7
After 0040-0041 37 0

0040 reaches further than its three sites: guarding the outermost init serialises the whole chain beneath it, so IGESData::Init’s proto/stmod/speci, XSAlgo::Init’s flag, IGESToBRep::theContainer and Interface_Static’s THE_Interface_Static_deja all stopped being reported without being touched.

The remaining 37 are all heap objects owned by the two controllers and shared with every work session, which is the ownership change #1403 is named for and which needs an API decision rather than a lock. None of these patches is in the pinned asset, so nothing changes for consumers until a rebuild.

Carried patches 0038 and 0039: two more data-exchange globals become per-instance (#1403)

0038, Interface_CheckTool’s errh sentinel. It decided whether FillCheck wraps each module CheckCase call in its own try. The six bulk list builders clear it because they wrap the whole loop, and they never restore it, so any bulk list operation left error handling off process-wide. A later direct FillCheck call then ran unguarded and a Standard_Failure that should have been caught and reported as a check fail escaped instead. FillCheck is public, so that sequence is reachable without threads at all.

0039, Interface_FileReaderData’s parameter memo. Param()/ChangeParam() cached the last resolved record in file-scope statics, gated by a global counter so only the most recently constructed instance could use the cache. mutable answers the declaration’s own stated blocker, “Fields not possible, because Param is const”, and the fix also makes the optimisation work at all: constructing any second reader disabled the memo for every earlier one, permanently. InitParams() now invalidates the memo, which the original never needed to because the next construction disabled it anyway.

Both follow #363’s relocate-to-the-owner precedent, with no locks and no signature changes. Measured by rebuilding the ThreadSanitizer kernel and re-running the five registered data-exchange scenarios: errh 7 reports to 0, thenm0 3 to 0, thefic 1 to 0. Across 0036, 0037, 0038 and 0039 together the five targeted globals all reach zero and total reports fall from 178 to 96 (Scripts/repro/1157-interface-static-thread-safety/gate-baseline-1403/).

Neither is in the pinned asset, so nothing changes for consumers until a rebuild. Both defects are live in current OCCT master, and both are held from upstream pending a GTest rather than filed without one.

Carried patch 0037: the STEP read actor’s non-manifold flag is per instance (#2061)

STEPControl_ActorRead held its non-manifold marker, NM_DETECTED, as a process-global. It is reset at the start of a shape-representation transfer, set when a non-manifold item is recognised, and read to decide whether an assembly component’s COMPOUND is flattened into its parent or kept nested. Concurrent STEP reads therefore shared a flag that gates shape construction.

Relocated to a per-instance myIsNMDetected, with no lock and no signature change, following #363’s relocate-to-the-owner precedent. Measured by override-link against a ThreadSanitizer build: the race is reported in 5 of 5 unpatched runs and 0 of 5 patched (Scripts/repro/2061-nm-detected/).

The wrong-shape outcome follows from the code and the flag demonstrably leaks between threads, but it did not occur in roughly ten thousand reads across three configurations, so this is recorded as a confirmed race rather than a demonstrated wrong answer. In practice the bridge’s igesMutex() serialises the whole data-exchange surface, so no consumer can reach it today.

Not in the pinned asset, so nothing changes for consumers until a rebuild. The defect is live in current OCCT master too, so the patch is bound upstream.

Carried patch 0036: IFSelect_WorkSession’s error-handling sentinel is per instance (#1403)

IFSelect_WorkSession used a file-scope errhand flag as a recursion sentinel so its nine error-handled operations would wrap themselves in a try exactly once. Shared across threads, one thread clearing the sentinel made another take the unguarded path, so an exception that should have been caught and reported escaped instead. It was the busiest racing site in the data-exchange path.

The global was only ever a mirror of the per-instance theerrhand, so it is removed in favour of a per-instance sentinel, with no lock, following #363’s relocate-to-the-owner precedent. Measured by override-link against a ThreadSanitizer build: six race access sites become zero (Scripts/repro/1403-workession-errhand/).

Not in the pinned asset, so nothing changes for consumers until a rebuild. The defect is live in current OCCT master too, so the patch is bound upstream.

Carried patch 0035 was added and retired without ever reaching a consumer (#1403, #280, #2056)

0035 backported the one line of OCCT#1259 the pinned kernel lacks, removing InitializeMissingParameters() from STEPControl_Writer::Transfer. It was retired the next day because it reintroduced #280: that call is not only an initialiser but the repair that re-sets DirectFaces on a shared actor an XDE STEP read has left with empty OperationsFlags. Without it, every STEP write following a Document.loadSTEP silently drops faces on indirect surfaces: a frustum came back with 2 faces instead of 3, missing 63% of its volume, still reporting isValid == true.

No consumer was ever affected. 0035 was never in the pinned asset, so only kernel-integration.yml, which builds the kernel from Scripts/patches/, ever ran it, and that is the job whose #280 regression guard caught it.

check-inventory-prose.py grew three claims and one structural check in the same work, after adding 0034 left three prose statements stale that the gate reported clean, and it now tolerates a capitalised or line-wrapped “the carried sequence now reads” phrase, which the retirement edit itself tripped over.

Carried patch 0034: GeomFill_CoonsAlgPatch::Value samples the U boundaries at U (#1515)

GeomFill_CoonsAlgPatch::Value(U, V) sampled all four boundaries at V, where bound[0] and bound[2] are the U-direction sides. For any boundary set whose V-direction sides are straight the result is independent of U and the surface collapses onto the u == v diagonal, with only u == v samples coincidentally correct.

The fix is two lines with every coefficient untouched, which #1515 had concluded was impossible: D1U in the shipped kernel is exactly the derivative of the corrected Value(), and a numeric probe confirms the corrected form is the exact bilinear surface (Scripts/repro/1515-coons-value-u-parameter/).

Shape.coonsAlgPatch is the only consumer that calls Value() directly; GeomFill_ConstrainedFilling evaluates through Eval() and was never affected. The patch is not in the pinned asset, so coonsAlgPatch is still wrong off-diagonal until a repin; its doc comment now says so.

API_REFERENCE’s category rows are censused against the Swift surface (#1679)

A removed public API could leave its name in an API_REFERENCE.md category row with every gate green, because count-operations.py treats those rows as illustrative and re-derives the headline totals, while check-docs-existence.py reads docs/reference/ pages rather than these tables. Scripts/census-api-reference-rows.py reports row entries that resolve to no declaration in Sources/.

It found one on merge: revolutionToElementary, removed by #1634, was still listed in the Healing/Analysis row. That row is corrected.

A census rather than a gate, measured: 79 of 2,588 identifier-shaped entries resolve to nothing, and most are correct documentation, because the rows mix real symbols with umbrella names (booleanCheck covers two bridge functions and is declared nowhere), abbreviations (thruSectionsCreate for OCCTShapeThruSectionsCreate) and category labels (boss).

The ten 3D GeomEval evaluators return optionals (#1669)

GeomEval’s ten curve and surface evaluators returned the zero vector when the call was refused, which is indistinguishable from a real answer at the origin, a point these curves and surfaces legitimately pass through. Each was a void bridge function writing out-parameters inside a try, so a caught throw left the caller’s buffer untouched. This is the defect #1646 fixed for the ten Geom2dEval functions, which scoped itself to 2D and left these deliberately.

All ten now return nil on a refusal: circularHelixD0/D1/D2, sineWaveD0/D1, ellipsoidD0, hyperboloidD0, paraboloidD0, circularHelicoidD0, hyperbolicParaboloidD0.

The success flag is read off the outputs rather than off the throw, because a throw is only one of three routes to a non-answer: a non-finite argument walks past OCCT’s validation, since every check is written <= 0 and every comparison against NaN is false, and finite arguments can still evaluate to a non-finite point. EvalD0/D1/D2 never raise for any parameter, so a finite result is the whole of what “succeeded” can mean. Derivative forms answer with every component or none.

Migration: unwrap the result. A caller that previously read the zero vector on failure was reading a value that was never a measurement. The ten docs/reference/ entries are corrected too; each previously documented the defect as the contract.

pointCloudByDensity(0.0) returns a cloud instead of hanging (#1452)

Auto-density is requested by passing 0.0, and it did not return. BRepLib_PointCloudShape::NbPointsByDensity validates its auto-computed density and then divides each face’s area by the caller’s original argument instead, so 0.0 divides by zero, and (int)std::ceil(+Infinity) saturates to INT_MAX. Every face asked for roughly 2.1 billion points. Reachable on an ordinary box.

The bridge now resolves auto-density itself before calling down, using the same computeDensity() the kernel would have used, so the kernel receives an explicit positive density and its divide is correct. Auto-density keeps working; refusing 0.0 would have closed the hang by removing the feature.

Shape.pointCloudByDensity(_:) gains documentation for what 0.0 means, which it never had, plus a runnable snippet. Measurements and probe in Scripts/repro/1452-pointcloud-auto-density/. No kernel patch is carried; the workaround is marked for retirement when the kernel is repinned.

Eighteen doc snippets called a Curve3D.arc factory that does not exist (#1675)

Curve3D.arc(center:radius:startAngle:endAngle:) appeared in 18 places across docs/reference/ and two /// comments. No such factory exists; Curve3D offers arcOfCircle(start:interior:end:) and arc(through:_:_:), both three-point. Every one of those snippets failed to compile, which matters because docs-current.md asks for runnable snippets so context7 indexes real code, and eighteen copies of one wrong spelling read as authoritative.

All 18 are translated to arcOfCircle, and the canonical translation is now pinned by a test that confirms it produces the curve the surrounding prose describes: curvature 0.2, centre of curvature at the origin, endpoints at (±5,0,0). Document-Geometry-Constructors.md’s concatenation example also gains a correction it always needed, since its arc started a unit away from the line it was concatenated onto and would not have joined even with a working factory.

TObjApplication’s shared singleton is serialized (#1404)

TObj_Application::GetInstance() returns one process-wide object, and two of its own fields were mutated with no synchronization: myIsVerbose, behind isVerbose’s getter and setter, and myIsError, which CreateNewDocument writes before calling NewDocument and reads back as its return value. Two concurrent createDocument() calls could each clear the other’s in-flight error signal, so a failed creation could be reported as a success.

Both are measured, not inferred: ThreadSanitizer reports a write-write race at TObj_Application.hxx:77 and a race at TObj_Application.cxx:172 against the pinned kernel (Scripts/repro/1404-tobjapplication/). In 800 operations the corruption never surfaced as a wrong answer, which is why it needed a sanitizer to find.

OCCTTObjApplicationSetVerbose, OCCTTObjApplicationIsVerbose and OCCTTObjApplicationCreateDocument now share tobjApplicationMutex(), held across the whole CreateNewDocument call rather than around the field writes. TObjApplication remains @unchecked Sendable, and that claim is now sound rather than aspirational: its doc comment used to tell callers to serialize the members themselves with OCCTSerial.withLock { }, which is no longer necessary.

This is distinct from the kernel races fixed by #341/#344/#349/#353/#371/#374, none of which touched this class. No kernel patch was needed and none is carried.

Scripts/tsan-stress.sh’s swift mode can fail (found during #1404)

do_swift ran swift test --sanitize=thread with no TSAN_OPTIONS, so ThreadSanitizer printed a detected race and the process still exited 0, because every suite in that mode’s filter is deliberately an exerciser asserting “did not deadlock, did not crash”. Measured on a tree with #1404’s lock removed: a run reporting a data race in TObj_Application::SetVerbose exited 0 and the gate called it a pass. do_run, the C++ half, has always set halt_on_error=0:exitcode=66:suppressions=$SUPP_FILE; the swift half never had it, for as long as the mode has existed, under a gate CLAUDE.md marks required for concurrency-touching changes.

Both halves now fail the same way under the same suppression file. Verified against #1404’s known-racy tree in both directions, and the full default filter (509 tests across 123 suites) is clean, so no suppression was added. Tooling only, no shipped behaviour change.

Removed fourteen BRepGraph entry points with no kernel path (#1652)

Eight BRepGraph setters silently discarded their arguments on the pinned kernel and six matching getters could only ever return nil. Measured against OCCT 8.0.1 rather than inferred from the bridge’s comments (probe and transcript in Scripts/repro/1652-brepgraph-noop-setters/), none of the fourteen has a kernel path, and none is reachable another way, so all fourteen are removed:

setCoEdgeUVBox(_:u1:v1:u2:v2:)
setVertexRefLocalLocation(_:matrix:)     vertexRefLocalLocation(_:)
setCoEdgeRefLocalLocation(_:matrix:)     coEdgeRefLocalLocation(_:)
setWireRefLocalLocation(_:matrix:)       wireRefLocalLocation(_:)
setFaceRefLocalLocation(_:matrix:)       faceRefLocalLocation(_:)
setShellRefLocalLocation(_:matrix:)      shellRefLocalLocation(_:)
setSolidRefLocalLocation(_:matrix:)      solidRefLocalLocation(_:)
repSetPolygonOnTriTriangulationId(_:triRepId:)

A BRepGraph reference carries a location only if its storage struct declares a LocalLocation field, and in 8.0.1 only BRepGraphInc::ChildRef and BRepGraphInc::OccurrenceRef do; there is no coedge reference kind at all. A coedge’s UV endpoints are derived from its PCurve, not stored. A polygon-on-triangulation resolves its triangulation through the owning face, not through a rep id.

Migration: use setOccurrenceRefLocalLocation(_:matrix:) or setChildRefLocalLocation(_:matrix:) to place topology, coEdgeSetPCurve(_:curve2D:) or coEdgeAddPCurve(edgeIndex:faceIndex:curve2D:first:last:orientation:) to move UV endpoints, and setFaceTriangulationRep(_:triRepId:) to change the triangulation a polygon-on-triangulation resolves against. Calls to the removed members were no-ops, so deleting them changes no behaviour.

Two adjacent doc comments in the same family were corrected against the same measurement: createPolygonOnTriRep(_:triRepId:) accepts a triRepId the bridge does not read, and coedgeRefCount returns the coedge definition count because no coedge reference kind exists.

Operation count: 4,365 to 4,351.

STEP and IGES failures now say why (#1644)

IFSelect_ReturnStatus, OCCT’s five-valued answer for every data-exchange step, reaches Swift as a new IOStatus enum instead of being collapsed to a Bool at all 33 entry points that produce one. Three error cases carry it:

do {
    let shape = try Shape.loadSTEP(fromPath: path)
} catch ImportError.readFailed(let path, .error) {
    print("\(path): check the path, OCCT could not open it")
} catch ImportError.readFailed(let path, .fail) {
    print("\(path): opened, but this is not a STEP file")
}
  • ImportError.readFailed(path:status:), thrown by the STEP and IGES loaders on Shape.
  • Exporter.ExportError.writeFailed(path:status:), thrown by the STEP writers and optimizeSTEP.
  • DocumentError.exchangeFailed(url:status:), thrown by the document STEP load and write entry points.

IOStatus keeps OCCT’s own names (void, done, error, fail, stop) and adds notReached for a call that failed before OCCT produced a status. The BREP, STL and IGES writers produce no such status and keep the importFailed/exportFailed cases they always threw.

In the bridge, 33 entry points take a trailing OCCTReturnStatus* _Nullable outStatus; passing NULL is exactly the old behaviour, which is what the counting entry points (Shape.stepRootCount and siblings) still do. OCCTBridge_Internal.h static_asserts each OCCTReturnStatus value against its IFSelect_ReturnStatus constant, so a kernel repin that renumbers that enum is a compile error rather than five silently relabelled values.

New reference page: docs/reference/IOStatus.md.

An analytic Contap contour carries its geometry (#1635)

ContapContourResult could report a contour as points, which is what Contap_Line holds for a numerically traced contour and for nothing else. A tangent ruling, a silhouette circle and a stretch of the face’s own boundary each live in a different Contap_Line accessor, and every one of those throws Standard_DomainError on a line of the wrong type, so a caller had to guess.

Six operations are added. geometry(line:) reads the line’s type first and returns the ContourGeometry case that applies: .line from Contap_Line::Line(), .circle from Circle(), .walking from the traced points, .restriction from Arc(). arcRange(line:) and arcPoint(line:parameter:) evaluate a boundary arc in the face’s own UV space. vertexCount(line:), vertex(line:index:) and vertices(line:) read the Contap_Point vertices, which exist on every contour type: a cylinder’s tangent ruling has two, where it meets the face’s boundary.

ContourVertex.parameterOnArc is Double? rather than Double, because Contap_Point::ParameterOnArc() throws Standard_DomainError when IsOnArc() is false and zero is itself a valid parameter. Measured against the pinned kernel in Scripts/repro/1635-contap-analytic-geometry/.

bsplineRestriction can convert planes, cylinders, cones, spheres, tori and Bezier surfaces (#1637)

ShapeCustom_RestrictionParameters was default-built inside the bridge and unreachable from Swift. Its fourteen per-kind switches decide whether ShapeCustom::BSplineRestriction touches a surface at all, and six of them default to false, so a cylinder came back with 0 BSpline faces and 3 still elementary with no diagnostic.

Shape.bsplineRestriction(tol3d:tol2d:maxDegree:maxSegments:continuity3d:continuity2d:degreePriority:rational:) gains a parameters: argument, defaulting to .occtDefaults, so existing calls are unchanged:

let untouched = cylinder.bsplineRestriction()                              // 0 BSpline faces
let converted = cylinder.bsplineRestriction(parameters: .allSurfaceTypes)  // 3 BSpline faces

var onlyCylinders = Shape.BSplineRestrictionParameters.occtDefaults
onlyCylinders.convertCylindricalSurface = true
let wallOnly = cylinder.bsplineRestriction(parameters: onlyCylinders)      // 1, the wall

Shape.BSplineRestrictionParameters carries all fourteen switches, each defaulting to the kernel’s own value, plus .occtDefaults and .allSurfaceTypes. A test compares the Swift defaults field by field against occtDefaultBSplineRestrictionParameters(), so they cannot drift from the kernel.

GMaxDegree and GMaxSeg are not exposed. They are the class’s two global caps and they are measured to have no effect next to the per-call maxDegree / maxSegments: on a torus, GMaxDegree of 3, 5 and 15 all deliver degree 7, while the per-call maxDegree of 3, 5 and 9 delivers 3, 5 and 7. Exposing them would be exposing a no-op.

The simpler bsplineRestriction(surfaceTolerance:curveTolerance:maxDegree:maxSegments:) keeps OCCT’s defaults deliberately, and its reference page now points at the configurable entry point.

ExtremaElSS reports only what Extrema_ExtElSS computes (#1632)

Breaking. ExtremaElSS.planeToSphere and ExtremaElSS.sphereToSphere are removed, and ExtremaElSS.planeToPlane returns (isParallel: Bool, squareDistance: Double?) in place of (isParallel: Bool, results: [ExtremaResult]).

Plane to plane is the only pair Extrema_ExtElSS implements. Its Perform overloads for plane/sphere, sphere/sphere, sphere/cylinder, sphere/cone and sphere/torus are all throw Standard_NotImplemented(); in OCCT itself, measured against the pinned 8.0.1 kernel in Scripts/repro/1632-extremaelss-refusal/. The two removed methods could not return a result on any input, and leaving them in to answer [] would have spelled a kernel gap exactly the way this namespace spells “no extrema found”. For either pair use Surface.extremaSS(other:), which is GeomAPI_ExtremaSurfaceSurface and answers both numerically.

planeToPlane reports no point pair because OCCT computes none. Extrema_ExtElSS::Perform(gp_Pln, gp_Pln) fills its square-distance array and leaves both point arrays as null handles, so Points() there is an uncatchable fault, and the pair the old shape handed back was SIMD3(0, 0, 0) rather than a measurement. Nor is there a pair worth fabricating: for two parallel planes every point of one, paired with its own projection onto the other, is a minimum. Crossing planes answer squareDistance == nil, since their distance is zero all along their intersection line and Extrema_ExtElSS records no extremum for that.

ExtremaResult.point1 and point2 lose the caveat that named this case, since it was the only one.

let r = ExtremaElSS.planeToPlane(
    plane1Point: .zero, plane1Normal: SIMD3(0, 0, 1),
    plane2Point: SIMD3(0, 0, 5), plane2Normal: SIMD3(0, 0, 1))
// r.isParallel == true, r.squareDistance == 25

Shape.updateEdgeTolerance takes the ceiling that decides whether it runs, and reports what moved (#1639)

OCCTBRepLibUpdateEdgeTolerance hardcoded BRepLib::UpdateEdgeTol’s MaxToleranceToCheck as tolerance * 100. That bound is what decides whether the call measures anything at all: OCCT returns false untouched when the edge’s own tolerance is already above it. Measured on a box whose edge tolerances were forced to 0.05 (Scripts/repro/1639/probe.mm):

   forced 0.05, maxCheck inf       before 0.05   returned true   after 1e-07   moved: YES
   forced 0.05, maxCheck tol*100   before 0.05   returned false  after 0.05    moved: no

maxToleranceToCheck is now a parameter, defaulting to .infinity, which examines every edge.

The return type changes from Bool to Shape.EdgeToleranceUpdate?. BRepLib::UpdateEdgeTol’s own Bool is true on every path that is not a refusal, so it never answered “did the tolerance change”; the new type carries toleranceBefore, toleranceAfter and the derived changed, and nil means the kernel refused (degenerate edge, or one already looser than the ceiling) or the input was not an edge.

let edge = imported.subShapes(ofType: .edge)[0]
if let update = Shape.updateEdgeTolerance(edge: edge, tolerance: 1e-7), update.changed {
    print("tolerance \(update.toleranceBefore) -> \(update.toleranceAfter)")
}

tolerance is still MinToleranceRequest and is still never written to the edge. OCCT can lower the tolerance as well as raise it.

Shape.composeShell can split a face (#1638)

The bridge wrapped the face’s own surface in a 1 x 1 ShapeExtend_CompositeSurface, and ShapeFix_ComposeShell cuts along the joints between patches, so a one-patch grid had nothing to cut along: one face went in and one came out, at any precision, with Perform() returning true.

composeShell gains uPatches and vPatches, both defaulting to 1, so existing calls behave exactly as before. The grid is the face’s own surface tiled over the face’s UV box as Geom_RectangularTrimmedSurface patches, so no extra caller input is needed:

if let face = Shape.cylinder(radius: 5, height: 10)?.subShapes(ofType: .face).first,
    let quarters = face.composeShell(uPatches: 4)
{
    print(quarters.subShapes(ofType: .face).count)  // 4, quarter cylinders
}

Measured on a 10 x 10 planar face: 2 x 1 gives 2 faces, 1 x 2 gives 2, 3 x 2 gives 6, 4 x 4 gives 16, and the pieces tile the original area exactly. On a cylinder’s lateral face, 2 x 1 gives 2 and 1 x 2 gives 2.

Even at the 1 x 1 default the call still does the wire rebuild, which is what it was documented as being good for.

ShapeExtend_Parametrisation is deliberately not exposed. The patches are sub-ranges of the face’s own surface, so ShapeExtend_Natural reproduces the face’s own parametrisation exactly and the face’s pcurves line up with the composite’s global UV; the other two modes renumber the joints away from the pcurves the face already carries.

Geom2dEval’s ten evaluators return optionals, so a refused call is not the origin (#1646)

Every Geom2dEval evaluator now returns an optional, and nil means the call was refused rather than answered. They were non-optional functions over void bridge calls that wrote out-parameters, so when #1629 caught the Standard_ConstructionError these curves raise on amplitude 0, radius 0 or growth rate 0, the caller was handed the origin and no way to tell it from a real answer. Geom2dEval.sineWaveD0(amplitude: 1, omega: 1, phase: 0, u: 0) is exactly (0, 0).

// A well-formed call answers.
if let p = Geom2dEval.sineWaveD0(amplitude: 1, omega: 2 * .pi, phase: 0, u: 0.25) {
    print(p)
}

// A zero amplitude is refused, not answered with the origin.
let refused = Geom2dEval.sineWaveD0(amplitude: 0, omega: 1, phase: 0, u: 0.5)
print(refused == nil)  // true

The affected methods are archimedeanSpiralD0/D1, logarithmicSpiralD0/D1, circleInvoluteD0/D1 in both the origin and the placement overloads, and sineWaveD0/D1. The ten corresponding OCCTGeom2dEval* bridge functions return bool instead of void.

A refusal covers more than a throw. Measured against the pinned kernel in Scripts/repro/1646-evaluator-contract/, OCCT’s own argument checks are written <= 0, and every comparison against NaN is false, so a non-finite argument constructs successfully and evaluates to a NaN point; and Geom2dEval_LogarithmicSpiralCurve(ax, 1, 1).EvalD0(1000) is NaN from arguments the constructor accepts. The flag is therefore taken from the outputs: a finite result is a measurement and nothing else is.

Shape.fixedFreeBounds returns the repaired shape, and the wires alongside it (#1636)

ShapeFix_FreeBounds::GetShape() was never read. The shape member of the result was a compound of the free-bound wires, so a caller asking for the repaired shape got something with no faces in it, and the fixedCount member was the number of closed wires rather than a count of repairs.

The return type changes from (shape: Shape, fixedCount: Int)? to Shape.FreeBoundsRepair?:

member what it is
shape ShapeFix_FreeBounds::GetShape(), the modified source shape
closedWires / openWires compounds of the free-bound wires, nil when there is none
closedWireCount / openWireCount how many wires are in each
let openShell = Shape.compound(box.subShapes(ofType: .face).dropLast())!
if let repair = openShell.fixedFreeBounds(sewingTolerance: 1e-6, closingTolerance: 1e-4) {
    print(repair.shape.subShapes(ofType: .face).count)   // 5, the faces are still there
    print(repair.closedWireCount, repair.openWireCount)  // 1 0
}

Migration: result.shape is now the repaired shape rather than the wires, and the old result.fixedCount is result.closedWireCount. To keep the previous behaviour exactly, read result.closedWires and result.openWires.

Two behaviours are documented rather than changed. closingTolerance must exceed sewingTolerance or OCCT performs no connection at all, which is the pinned header’s own precondition and is still unenforced. And the analyser wants a compound of faces: a bare face comes back with zero wires of either kind.

MathSolver.eigenvalues / .eigenvaluesAndVectors take the n-1 real off-diagonal entries (#1643)

The subdiagonal parameter is now offDiagonal, and it takes diagonal.count - 1 entries rather than diagonal.count:

// Before: n entries, and OCCT silently discarded subdiagonal[0].
MathSolver.eigenvalues(diagonal: [2, 2, 2], subdiagonal: [0, -1, -1])
// After: the n - 1 entries the matrix has, in matrix order.
MathSolver.eigenvalues(diagonal: [2, 2, 2], offDiagonal: [-1, -1])   // 2 - sqrt(2), 2, 2 + sqrt(2)

math_EigenValuesSearcher’s shiftSubdiagonalElements copies work(i-1) = work(i) over 2...n and then zeroes work(n), so the caller’s first element never reached the matrix. Three doc layers said the last one was the dead slot until #1399 measured it, and the example that shipped in MathSolver.eigenvalues’ own doc comment, subdiagonal: [1.0, 1.0, 0.0], therefore computed the spectrum of off-diagonals (1, 0) while claiming (1, 1): [1, 3, 2] rather than 2 - sqrt(2), 2, 2 + sqrt(2). Renaming the label with the shape makes an un-migrated call a compile error rather than a silently different matrix.

A 1x1 matrix now takes an empty offDiagonal, and diagonal must be non-empty. The bridge’s OCCTMathEigenValues / OCCTMathEigenValuesAndVectors changed the same way, with offDiagonal nullable only when n == 1.

Shape.revolutionToElementary() is removed; it ran its own inverse (#1634)

ShapeCustom::ConvertToRevolution converts elementary periodic surfaces into surfaces of revolution, the opposite of what revolutionToElementary() said and of what all three doc layers claimed. It called the identical OCCT static as withSurfacesAsRevolution(), through a second bridge function (OCCTShapeRevolutionToElementary) whose body was byte-identical to OCCTShapeCustomConvertToRevolution’s.

Both the Swift method and the bridge function are removed. Migration:

  • for the direction the method actually ran, call withSurfacesAsRevolution();
  • for the direction its name promised, call sweptToElementary(), which has always been ShapeCustom::SweptToElementary.

Measured on a cylinder, withSurfacesAsRevolution() takes the surface-of-revolution face count from 0 to 1 (the lateral face; the two planar caps are left alone) and sweptToElementary() takes it back to 0. Face.surfaceType cannot see this: it is BRepAdaptor_Surface::GetType(), which canonicalises a surface of revolution built on a line back to GeomAbs_Cylinder. Read Shape.extractFaceSurface()?.typeName instead.

Curve3D.extrema and minimumDistance take the curve’s endpoints into account (#1633)

Curve3D.extrema(from:), Curve3D.extrema(from:uMin:uMax:) and Curve3D.minimumDistance(from:) reported interior extrema only. The bridge called ExtremaPC_Curve::Perform, the interior solve, rather than PerformWithEndpoints, so a query point with no perpendicular foot on the curve, which is every point past the end of a bounded one, came back [] and nil. A segment [0, 10] along +X queried from (20, 0, 0) answered nil where the distance to the nearer end is 10.

let seg = Curve3D.segment(from: SIMD3(0, 0, 0), to: SIMD3(10, 0, 0))!
seg.minimumDistance(from: SIMD3(20, 0, 0))   // 10.0. Was nil.
seg.extrema(from: SIMD3(20, 0, 0)).count     // 2, the domain's two ends. Was 0.

The interior solve’s only extremum can also be a maximum, so this was not only a missing value: a half circle of radius 5 queried from (0, -6, 0) reported its minimum distance as 11, the far side of the arc, where the true minimum is either end at sqrt(61) = 7.8102.

A closed curve such as a full circle, and an unbounded one such as a line, have no domain ends to add and are unchanged. This is the distinction #580 settled for Shape.pointEdgeExtrema(point:edgeIndex:). Measured across every curve kind ExtremaPC_Curve dispatches over in Scripts/repro/1633-extremapc-endpoints/.

Three unreachable bridge functions removed, and the functions they duplicated got real tests (#1640)

OCCTWireMakePolygonFromPoints, OCCTShapeUpgradeClosedFaceDivide and OCCTShapeUpgradeSplitSurfaceArea were compiled into every build and called from nothing. Measured against the entry point each duplicates, on the pinned kernel, all three are exact duplicates:

  • OCCTWireMakePolygonFromPoints and OCCTWireCreateFastPolygon build the same 4-edge, closed, length-40 wire. BRepBuilderAPI_MakePolygon holds a BRepLib_MakePolygon member and delegates to it.
  • OCCTShapeUpgradeClosedFaceDivide and OCCTShapeUpgradeDivideClosed give the same 4, 5 and 6 faces on a cylinder at 1, 2 and 3 split points, and the same 3 on a sphere.
  • OCCTShapeUpgradeSplitSurfaceArea and OCCTShapeDivideByParts give the same face count and the same volume at 2, 3, 4 and 9 parts.

No Swift API changes. The C surface loses three symbols that nothing in this repo, and nothing that could have been tested, ever called.

Shape.dividedByParts(_:) and Shape.dividedClosedFaces(splitPoints:) gain real assertions and two measured behaviours in their reference pages: dividedByParts(1) and dividedClosedFaces on a shape with no closed face both return nil, which means “nothing to do” rather than “failed”; and a dividedByParts result that was split on both axes comes back BRepCheck_Analyzer-invalid with its volume preserved exactly, which is a property of the two-axis split rather than of the entry point (OCCTShapeDivideByNumber(shape, 2, 2) is equally invalid).

The four dead math_* adapter copies in the Spatial bridge split are gone (#1645)

OCCTMathFuncAdapter, OCCTMathFuncSetAdapter, OCCTMathMultiVarAdapter, OCCTMathMultiVarGradAdapter, OCCTMathHessianAdapter and OCCTMathSimpleFuncAdapter were defined in all five OCCTBridge_Spatial_*.mm files and instantiated only in _MathSolvers.mm. The four unused copies (944 lines) are deleted, and the live definitions now sit in an anonymous namespace, which drops 68 externally visible weak vtable and type-info symbols from the link and makes the identical-copies-across-TUs shape that produced #1418 impossible for these six. Internal only: no bridge function, Swift API or behaviour changed.

reachable()’s wrapper-type expansion follows a chain, not one hop (#1642)

check-bridge-index.py’s reachability walker expanded bridge wrapper types exactly once, over a snapshot of each function’s name set, so a class held by a type held by a type was reported as not reached. OCCTSelectorPick names OCCTSelectorRef, OCCTSelector holds OCCTHeadlessSelector, and that class holds the SelectMgr_SelectingVolumeManager it calls InitPointSelectingVolume on; hop one was taken and hop two was not.

census-doc-occt-attribution.py borrows the same walker, so the effect there was to score a corrected attribution worse than the wrong one it replaced: naming what the bridge actually calls removed three docs/reference/Selection.md findings and added four false ones. The type table is now closed over itself before the per-function expansion, bounded like the helper loop beside it. Whole-tree census findings fall from 233 to 228 with nothing new appearing, recall against the 32 known findings is unchanged, and 14 of 4,269 bridge functions gain reach.

Scripts/repro/928-over-coverage-detector/validate_known_findings.py is also fixed: it had been unrunnable since the census’s run() grew a fourth return value, and it is the only tool that measures that recall.

Shape.splitDrafts is removed (#1393)

The operation could not succeed on any input. LocOpe_SplitDrafts accepts only a planar face and then pipes along the intersection of two planes, always an infinite line, which GeomConvert::CurveToBSplineCurve refuses by documented design, so every valid call threw and the bridge returned nil.

It is removed rather than repaired because OCCT removed it first: the class was deleted upstream on 2026-08-07 in OCCT#1442 as dead code with no caller in the OCCT tree. Carrying a kernel patch to revive it would have expired at the next repin and could never have been filed upstream.

Gone with it: OCCTLocOpeSplitDrafts, its declaration and cross-reference index row, eleven unused #includes, the reference-page section and the API_REFERENCE entry. The investigation is kept in Scripts/repro/1393-splitdrafts/, including the GTest written for the upstream PR that could not be filed, since it is the only executable statement of what a working LocOpe_SplitDrafts produces.

The unlaned refman-coverage lane (#1399)

Scripts/repro/1399-refman-coverage-unlaned/ audits the 643 wrapped classes #820 found in no lane’s table. They split into 479 checked continuously by census-doc-occt-attribution.py, 138 real algorithm classes read by hand in four families, and 26 containers. Across the 164 read by hand: ok 41, deliberate, recorded 64, under 11, over 48.

over dominating is the opposite of what the pass expected. A class named nowhere in docs/ almost always meant the capability was documented under its Swift name with the wrong OCCT class beside it: a neighbour, a base class, a header filename, or a sub-view of the right object.

The larger finding is that the census’s 431 findings had never been read; 212 were real. Fourteen code defects came out of an audit whose subject was documentation.

Fourteen OCCT attributions in docs/reference/ named a class the bridge does not reach (#1399)

The healing family of #1399’s unlaned-coverage read (ShapeFix/ShapeAnalysis/ShapeUpgrade/ ShapeCustom/ShapeExtend, BRepTools/BRepLib/BRepTopAdaptor, BRepBndLib/BndLib/Bnd_*, BRepGProp/GProp_*, BRepGraph_*) checked all 31 classes against the pinned 8.0.1 headers and the real bridge bodies. Documentation only; no API or behaviour changes.

  • Eleven GProp attributions. GeometryProperties.cylinderSurfaceArea, cylinderVolume, coneSurfaceArea, coneVolume, sphereSurfaceArea, sphereVolume, torusSurfaceArea and torusVolume were attributed to GProp_PGProps, GProp_PEquation or GProp_GProps. Every surface member builds GProp_SelGProps and every volume member builds GProp_VelGProps. GProp_PGProps is the point-set class the pointSetCentroid family really uses, on the same page.
  • Eight BndLib attributions. BndLib.ellipse, .cone, .circleArc, .ellipseArc, .parabolaArc, .hyperbolaArc, .line and .sphere named BndLib_Add3dCurve or BndLib_AddSurface; all eight call BndLib::Add overloads directly. BndLib.edge and BndLib.face are the only two entry points that really reach the adaptor classes, and they were already correct.
  • Eleven BRepGraph identity and copy attributions. copy, copyFace and translated named only their bridge function, and the eight UID entries named the input type (BRepGraph_NodeId, BRepGraph_RefId) or “the item-UID layer”. They now name BRepGraph_Copy::Perform / ::CopyNode, BRepGraph_Transform::Perform, and the UIDs() registry call each function makes, with BRepGraph_UID / _RefUID / _ItemUID / _ItemId named for the first time outside the changelog.
  • Shape.revolutionToElementary() runs the inverse of what it says. It calls ShapeCustom::ConvertToRevolution, which the pinned header documents as converting elementary periodic surfaces into surfaces of revolution. Measured: a cylinder’s lateral face comes back as a Geom_SurfaceOfRevolution, and sweptToElementary() puts it back. All three doc layers said the opposite and no test covered it. withSurfacesAsRevolution() is a second wrapper of the identical bridge call, correctly documented. The rename is #1634.
  • Shape.updateEdgeTolerance(edge:tolerance:) does not set a tolerance. It calls BRepLib::UpdateEdgeTol, not the whole-shape UpdateEdgeTolerance the page named, and tolerance is MinToleranceRequest, a sampling floor. Measured across two edges and four requested values from 1e-9 to 2, the edge tolerance stayed at 1e-07 every time while the call returned true. The hardcoded tolerance * 100 ceiling is #1639.
  • Shape.composeShell(precision:) cannot split a face. The bridge wraps the face’s own surface in a 1 x 1 ShapeExtend_CompositeSurface, and ShapeFix_ComposeShell splits along joints between patches. Measured: one face in, one face out, Perform() true. What it does perform, the wire rebuild, is now what both doc surfaces describe. Taking a real grid is #1638.
  • Shape.bsplineRestriction(...) converts no elementary surface. Both entry points pass a default-built ShapeCustom_RestrictionParameters, whose defaults leave planes, cylinders, cones, spheres, tori and Bezier surfaces alone and convert only revolution, extrusion and offset surfaces plus curves. Measured: a cylinder comes back with three elementary faces and no BSpline. Exposing the toggles is #1637.
  • Shape.nearestPlane(to:) is not a planarity test. ShapeAnalysis_Geom::NearestPlane refuses only when the smallest principal extent reaches half of one of the other two. Measured: one corner of a 10 x 10 square lifted 8 units out of plane fits with a maxDeviation of 2.13, and the cutoff on that sheet is a thickness of 5. Gate on maxDeviation. Attribution corrected from gp_Pln, its out-parameter, to ShapeAnalysis_Geom::NearestPlane through GProp_PEquation.
  • Four more single-class corrections. fixedFreeBounds is ShapeFix_FreeBounds, not ShapeFix_Shape (#1636 for its returning a wire compound rather than the repaired shape); purgedLocations is BRepTools_PurgeLocations, not BRepLib::SameParameter; curveOnSurfaceCheck is BRepLib_CheckCurveOnSurface, not ShapeAnalysis_Edge; recognizeCanonical is ShapeAnalysis_CanonicalRecognition, not BRepGProp.
  • dividedByNumber(_:)’s entry was pre-#1491. It said “approximately parts patches”; since #1491 the count is exact and per-axis and lands on U. The Swift /// was updated by that PR and the reference page was not.
  • checkOuterBound’s entry described two defects PR #1140 had already fixed. It said the cancellation and partial-pcurve gaps were open “because the fix is a magnitude threshold against the face’s own UV scale and nobody has measured what it should be”; that threshold has been in OCCTWireCheckOuterBound since 2026-08-26, alongside an every-edge-pcurve requirement. nil now covers five inputs, not four.

Scripts/repro/1399-refman-coverage-unlaned/probe_healing_claims.mm and its committed transcript replay five of these bridge call sequences against the pinned kernel; family-healing.md beside them carries the 31-class table, the evidence per finding, and the six census-doc-occt-attribution.py candidates rejected after reading the bridge.

Read the booleans family of #1399’s unlaned refman-coverage lane (#1399)

31 OCCT classes with real bridge presence that sit in no #807 lane’s table and that no claim census-doc-occt-attribution.py parses, given one verdict each with the evidence in Scripts/repro/1399-refman-coverage-unlaned/family-booleans.md: 19 ok, 6 deliberate, recorded, 1 under, 5 over. Six findings, measured against the pinned 8.0.1 kernel by Scripts/repro/1399-refman-coverage-unlaned/probe_booleans.mm (transcript committed beside it).

Four of the six are behaviour, and are filed rather than changed here: #1631 (Shape.edgeFaceIntersection(with:) returns an empty array for every input, because OCCTIntToolsEdgeFace never calls IntTools_EdgeFace::SetRange and IntTools_Range’s default is (0, 0)), #1632 (ExtremaElSS.planeToSphere and sphereToSphere always return [], because Extrema_ExtElSS::Perform is throw Standard_NotImplemented(); for both pairs in OCCT itself, and planeToPlane answers only its parallel case with zeroed points), #1633 (Curve3D.minimumDistance(from:) and extrema report interior extrema only, so a point past the end of a bounded curve gets nil rather than the distance to the nearer endpoint), and #1635 (ContapContourResult has no reachable geometry for an analytic silhouette).

The documentation corrections in this change:

  • docs/reference/Curve3D-Analysis.md attributed Curve3D.extrema(from:), extrema(from:uMin:uMax:) and minimumDistance(from:) to Extrema_ExtPC at six sites, one of them “with bounded GeomAdaptor_Curve”. The bridge constructs ExtremaPC_Curve from the Geom_Curve handle and builds no adaptor. The three entries now name the real class and state that the solve reports interior extrema only.
  • Shape.FilletSurfaceInfo.startStatus/endStatus were documented as “(0 = ok, 1 = not ok, 2 = partial)”, which is FilletSurf_StatusDone, the enum on the sibling FilletSurfaceResult.status. They carry FilletSurf_StatusType, whose ordinals are TwoExtremityOnEdge/OneExtremityOnEdge/NoExtremityOnEdge. OCCTBridge_Modeling.h’s own field comment had the first two transposed and wrote “OnFace” for OCCT’s “OnEdge”. firstParameter/lastParameter were undocumented, and come from FirstParameter()/ LastParameter(), which take no surface index, so the same pair is repeated into every element.
  • Shape.CommonPart.param2Range was documented as the parameter range “on the second edge”. From edgeFaceIntersection(with:) there is no second edge and the value is always (0, 0): IntTools_EdgeFace never calls AppendRange2 or SetVertexParameter2, so Ranges2() is empty and VertexParameter2() is the 0.0 its constructor set.
  • ContapContourResult.pointCount(line:), point(line:index:) and points(line:) answer for .walking contours only. Contap_Line::NbPnts() and Point(Index) throw Standard_DomainError on any other type, so an analytic silhouette (a cylinder’s two tangent rulings, the common case) reports 0 points and point(line:index:) hands back SIMD3(0, 0, 0), a zero rather than a measurement.
  • QuadricIntersection.coneSphere was attributed to IntAna_QuadQuadGeo. The bridge builds an IntAna_Quadric from the sphere and runs IntAna_IntQuadQuad.
  • Shape.polygonInterference(poly1:poly2:) and polygonSelfInterference(polygon:) cap their output at 100 points and truncate silently, which is now stated as the sibling Curve2D.intersections(with:) already states its own 128.

The pass also adjudicated census-doc-occt-attribution.py’s own boolean and extrema findings, which are parsed on every run and had never been read. The census reported 431 findings at this branch’s base commit and reports 421 here, with no new findings added: beyond the two above, it had been reporting that Curve3D.projectPointAll is GeomAPI_ProjectPointOnCurve rather than GeomAPI_ExtremaCurveCurve/Extrema_ExtPC, that Surface.locateNearestPoint is Extrema_GenLocateExtPS rather than the global Extrema_ExtPS, and that Shape.split(by:) and split(atPlane:normal:) run BRepAlgoAPI_Splitter rather than its base class BRepAlgoAPI_BuilderAlgo, which is General Fuse and returns a compound of split parts rather than splitting arguments by tools.

#1399 geometry family: 25 over-coverage corrections and a measured knot-splitting contract (#1399)

Twenty-five documentation claims about the Geom/Geom2d/Convert/Gcc/ProjLib/HelixGeom/ Law/LProp families corrected against the pinned OCCT 8.0.1 headers and the bridge, as part of #1399’s reading of the 118 wrapped classes no #807 lane claims.

  • Eight OCCT class names that do not exist were named as the implementation of a public member: HelixGeom_Helix and HelixGeom_ApproxCurve behind the Helix builders and evaluators, LProp_AnalyticCurInf behind Shape.analyticCurvaturePoints, and Geom2dGcc_Circ2d2TanPt, Geom2dGcc_Circ2dTanPtRad, Geom2dGcc_Circ2d2PtRad, Geom2dGcc_Circ2d3Pt and Geom2dGcc_Lin2dTanPt behind five Curve2DGcc members. Each is the bridge function’s own name with an OCCT package prefix attached. They now name what the bridge builds: HelixGeom_BuilderHelix, HelixGeom_BuilderHelixCoil, HelixGeom_HelixCurve, HelixGeom_Tools::ApprHelix, LProp_CurAndInf, and the Geom2dGcc_Circ2d3Tan / Circ2d2TanRad / Lin2d2Tan constructors that take a Geom2dGcc_QualifiedCurve or a Geom2d_CartesianPoint.
  • The GeomEval and Geom2dEval analytic factories are OCCT classes, not ours. Twenty entries across docs/reference/Surface.md, Curve2D.md and Document-Completions.md gave a bridge C function under - **OCCT:**, and two preambles described the family as project-local evaluators “backed by Geom_CartesianPoint-derived evaluator surfaces”. Every entry now names its real class, and the preambles say what these derive from.
  • Shape.analyticCurvaturePoints never returns an inflection, and only an ellipse produces any point at all: a line, a circle, a parabola and a hyperbola have no curvature extremum. The entry also now says that LProp_CurAndInf classifies by radius of curvature, so an ellipse’s major-axis vertices come back as .minimumCurvature.
  • BisecSolution’s conic payload was described backwards. position is the centre for an ellipse or a hyperbola and the vertex for a parabola, never a focus; radius is 0 for every conic and the semi-axes are in secondary, except for a parabola whose secondary.x is a focal distance. Corrected in the reference page, in the ///, and in Sources/OCCTBridge/include/OCCTBridge_Geom2d.h.
  • LawFunction.knotSplitting(continuityOrder:) and knotSplitParameters(continuityOrder:) read four of the seven law factories, not the one the docs named. Law_Interpol and Law_S derive from Law_BSpFunc, so interpolate(points:periodic:) and sCurve(from:to:parameterRange:) are readable alongside bspline(...) and interpolated(...); constant, linear and composite are not. A readable law always reports at least its two end knots, so an empty array means “not a Law_BSpFunc-derived law” rather than “no discontinuities”. Measured and covered by Tests/OCCTCurveTests/Issue1399LawKnotSplitFactoryReachTests.swift.
  • Curve2DQualifier’s “inside” is orientation-dependent: GccEnt_Position.hxx defines the interior of a line or an open curve as its left-hand side relative to its own orientation, so reversing a curve swaps what .enclosing and .enclosed select. The claim that a qualifier is passed “in every Curve2DGcc solver call” is also gone: the all-point members take none.
  • Surface.hyperboloid(r1:r2:twoSheets:) returns one sheet when twoSheets is true; GeomEval_HyperboloidSurface represents a single connected surface. Both parametrisations, and the parametric ranges of the other four GeomEval surface factories, are now documented.
  • uIsoCurvePoints/vIsoCurvePoints clamp an infinite iso to -1e6...1e6, and return count points at the origin rather than an empty array when the shape is not a face.
  • Curve3D.join(_:) uses GeomConvert::CurveToBSplineCurve + GeomConvert_CompCurveToBSplineCurve::Add, not GeomConvert::ConcatG1, which the bridge never calls.
  • Shape.uniformDeflection(_:) uses CPnts_UniformDeflection, not GCPnts_UniformDeflection; this repo wraps that one separately, behind Curve3D.drawDeflection and Curve2D.drawDeflection.
  • The ProjLib projectors construct ProjLib_Plane/ProjLib_Cylinder and read IsDone()/Line()/Circle(), rather than calling ProjLib::Project.
  • Geom2dEval.circleInvoluteD0(origin:direction:radius:u:) and its D1 sibling were public and documented nowhere; both now have reference entries.
  • The ten Geom2dEval_* evaluators’ current failure contract is documented and tracked as #1646: they have no try/catch over constructors that throw on out-of-range arguments.

Foundation-family documentation corrections from #1399’s unlaned-class read (#1399)

Seven documentation defects found by reading the 32 OCCT classes in #1399’s foundation family against the pinned kernel, one at a time. No behaviour changed; every correction is a comment or a reference page.

  • MathSolver.eigenvalues / eigenvaluesAndVectors: the ignored subdiagonal element is the first, not the last. math_EigenValuesSearcher shifts its working sub-diagonal down by one and zeroes the tail, discarding the caller’s element 0. MathSolver.swift, docs/reference/Document-Transforms.md and OCCTBridge_Spatial.h all said “last element unused”, and the example shipped in the doc comment, eigenvalues(diagonal: [2, 2, 2], subdiagonal: [1, 1, 0]), returns [1, 3, 2] rather than the 2-sqrt(2), 2, 2+sqrt(2) it implies. Both entries now state the real convention, note that the return order is undefined by OCCT’s own header, and carry a corrected runnable example. The API-shape question is #1643.
  • The same two entries named math_EigenVectors, a class that does not exist in the pinned kernel. They now name math_EigenValuesSearcher::EigenValue / EigenVector.
  • The relative mesh deflection is the longest bounding-box side times four, not the diagonal. Prs3d::GetDeflection is max(aDiag.maxComp() * coefficient * 4.0, Precision::Confusion()). docs/reference/Drawing.md, docs/reference/Display.md, DisplayDrawer.deviationCoefficient’s doc comment and the bridge’s #1418 comment are corrected, and the formula is written out with the measured values (0.004 / 0.04 / 0.4 for 1-, 10- and 100-unit cubes at the 0.001 default). Display.md also said the deflection was “read from Prs3d_Drawer”; for OCCT’s default relative type it is computed from a Bnd_Box the bridge builds.
  • Selector.pick does not call SelectMgr_ViewerSelector::Pick. All four Pick overloads require a V3d_View, which the headless selector exists to avoid. The three pick entries and Selector.init() now name SelectMgr_SelectingVolumeManager’s volume builders, SelectMgr_ViewerSelector::TraverseSensitives, SelectMgr_SortCriterion and SelectMgr_EntityOwner, and the rectangle entry names PickBox rather than the bridge function’s PickRect.
  • OCCTLengthUnit’s documented declaration had the wrong raw values. UnitsMethods_LengthUnit skips 3, so foot is 4. The reference page’s compressed case list would have given foot the value 3 and shifted every case after it.
  • OCCTPrecision.pConfusion is a constant 1e-9, not “scaled by curve-space bounds”: Precision::PConfusion() takes no argument. intersection (1e-9) and approximation (1e-6) gain their values, and OCCTPrecision gains the runnable snippet docs-current asks for.
  • Exporter.optimizeSTEP deduplicates through XSControl_WorkSession. StepTidy_DuplicateCleaner runs on STEPControl_Reader::WS() before TransferRoots, which is why the call takes a file path rather than a Shape. Neither class was named on the reference page.
  • OSD::SetSignal does not make OCCT’s signals catchable in this build. OCC_CONVERT_SIGNALS is undefined, so OCC_CATCH_SIGNALS expands to nothing. The two bridge comments that claimed otherwise now say what the handler does and cross-reference the #263 note in the same file that already said so.

Filed rather than fixed: #1641 (the attribution census cannot see any class whose name ends in an all-uppercase word: 45 claim sites, 14 real classes), #1642 (reachable()’s wrapper-type expansion is single-pass, so a correct attribution scores worse than the wrong one it replaced), #1643 (the subdiagonal API shape), #1644 (IFSelect_ReturnStatus collapsed to Bool at 44 sites), #1645 (six math_* callback adapters compiled into five files and instantiated in one).

Reference pages attribute the OCCT class the bridge actually calls (#1399)

Adjudicated all 431 findings from Scripts/census-doc-occt-attribution.py, a detector that had been running on every push with nobody reading its output. 212 were real and are corrected; the census now reports 230.

The three BRepGraph reference pages named BRepGraph_EditorView, BRepGraph_CoEdge, BRepGraph_RepStore and six more classes that do not exist in the pinned kernel; the real ones are BRepGraph::EditorView’s Ops sub-views, BRepGraph::Topo(), BRepGraph_Tool and BRepGraph_LayerHistory. Eleven mutation entries named the wrong Ops sub-view. Eighteen further pages named a class absent from OCCT 8.0.1, among them Geom2dGcc_Circ2d2TanPt (the solver is Geom2dGcc_Circ2d3Tan), math_Laguerre (MathPoly::Laguerre), HelixGeom_Helix (HelixGeom_BuilderHelix) and Draft_MakeDraft (BRepOffsetAPI_DraftAngle). Sixty-nine more named a real class that is not the one running, such as GProp_PGProps for six analytic properties that use GProp_SelGProps and GProp_VelGProps.

Eight BRepGraph setters and six matching getters are silent no-ops on the pinned kernel and were documented as if they worked; each now carries the note setEdgeRegularity already had, and the API-surface question is #1652.

The adjudication, including all 210 false positives with the reason the detector was wrong and a measured 49.8% false-positive rate over the whole set, is in Scripts/repro/1399-refman-coverage-unlaned/census-findings.md.

Shape.edgeFaceIntersection can find an intersection (#1631)

OCCTIntToolsEdgeFace never called IntTools_EdgeFace::SetRange, whose default is (0, 0), so the intersector was given an empty interval on the edge and returned zero common parts for every input while reporting IsDone(). It now sets the edge’s own parameter range. On a 10-unit box with an edge through its middle, the two faces the edge crosses report a common part where all six previously reported none.

The function also now checks that its two handles really are an edge and a face before casting them.

A gate for the repo’s own counted claims about its inventories (#1408, #1066)

Scripts/check-inventory-prose.py derives how many patches are carried, how many the pinned kernel holds, and how many gates, censuses and audits ci.yml’s gate-scripts job runs, then checks sixteen counted claims in Package.swift, CLAUDE.md, ci.yml and okf/policies/static-gates.md against them. A claim whose sentence no longer matches its regex fails as loudly as a wrong number, so a rewording cannot quietly drop a check.

It found three live defects when first run: ci.yml’s gate-scripts comment claimed “all five” scripts against a job running thirteen and “the other four” against twelve (#1066), and okf/references/carried-occt-patches.md keyed a row 0010-Intf_Interference-…-319, an ellipsis that names no file on disk. A fourth surfaced with it: the 0027 row was keyed to a name the patch file does not have. All fixed. The gate is the ninth in the gate-scripts job, and #819’s gate-coverage audit moves stale-self-referential-count from ungated-gap to gated.

The attribution census sees a bullet that names no OCCT class (#1399)

Scripts/census-doc-occt-attribution.py silently skipped any claim from which it could extract no class name, so a - **OCCT:** bullet answering with a bridge C symbol instead of an OCCT class was invisible to it. #1399’s geometry family found twenty such entries on the GeomEval/Geom2dEval surfaces while the census called both packages clean. The census now reports them as their own category, 183 sites at time of writing, and its self-test gained three cases including the table-row case that pins the deliberate restriction to the - **OCCT:** bullet channel.

Its summary line also counted from a literal (total = 20) while 23 cases printed; it is now derived from what ran.

A gate for throwing OCCT calls, and the live abort it found (#1407)

Scripts/check-throwing-calls.py checks that every gp_Dir/gp_Dir2d/gp_Ax*/Geom_Direction construction and every D1/D2 evaluator in Sources/OCCTBridge/src is inside a try, guarded by a length test the function already performs, or in a helper whose callers catch. This is the #345 defect class, where an exception crossing into Swift is a process abort rather than an error, and where 49 sites were fixed by hand with nothing to keep them fixed.

GeneralTransform2D.affinity(axisOrigin:axisDirection:ratio:) now returns GeneralTransform2D?. It was the one live site: a zero-length axisDirection reached gp_Dir2d unguarded and aborted the process. It returns nil for a direction shorter than gp::Resolution(), and the bridge function returns bool.

Shape.checkSolid(), and checkResult localizes solid-level defects (#1392)

BRepCheck_Solid is reachable from Swift for the first time: OCCTCheckSolid had been implemented and documented since v0.x with no Swift caller, so nothing could reach it. Shape.checkSolid() checks every solid in the shape and reports isValid, errorCount and firstError like the rest of the check family. It answers what the per-sub-shape checks cannot: shell imbrication, an enclosed region no shell declares as a void, a subshape not in the shape.

Shape.checkResult no longer reports zero errors on a shape it has just called invalid. It set isValid from BRepCheck_Analyzer but localized the error by walking only faces and edges, so a solid- or shell-level defect left errorCount at 0 and firstError at .noError. It now walks shells and solids as well.

Curve-surface extrema carry both surface parameters (#1514)

Curve3D.extremaCSPoint(range:surface:index:) returns a new CurveSurfaceExtremaPoint instead of ExtremaPointPair: the surface-side point of a curve-to-surface extremum has two parameters, and the old struct had room for one, so OCCTExtremaExtCSPoint computed V and discarded it. The surface point can now be re-evaluated from the parameters the call reports (surface.point(atU: p.u2, v: p.v2) == p.point2). The same call also now guards its null handles rather than dereferencing them, and the reference page’s two conflicting descriptions of the old encoding ((u, v, 0) packing, and point2.z carrying V) are removed: neither was true.

At the C bridge, OCCTExtremaExtCSPoint returns the new OCCTExtremaCSPointPair.

Shape.splitDrafts is covered, and documented as unusable on this kernel (#1393)

LocOpe_SplitDrafts had no test anywhere in the tree. It has one now, and what it records is that the operation cannot succeed on OCCT 8.0.1: LocOpe_SplitDrafts accepts only a planar face and then pipes along the intersection of two planes, always a Geom_Line, and GeomConvert::CurveToBSplineCurve has no line case, so GeomFill_Pipe throws Standard_DomainError("No such curve") on every valid call. Shape.splitDrafts therefore returns nil for every input, which the bridge’s existing catch (...) already did correctly.

The reference page now says so, the defect is recorded in okf/references/known-occt-bugs.md, and both reproducers are committed under Scripts/repro/1393-splitdrafts/. The upstream fix, a Geom_Line case in GeomConvert (a line is a degree-1 B-spline with two poles), is queued rather than carried as a patch. No behavior changes in this PR.

Twelve TDataStd_* OCAF suites move into OCCTXCAFTests (#1396)

Ten suites inside the OCCTFoundationTests monolith and two files in OCCTModelingTests all test plain OCAF label-attribute round-trips, the same shape as the TDataStd_* suites already in OCCTXCAFTests. They move verbatim, one file per attribute. Test-only: no source, no public API and no test body changes, and the same 36 tests run in the same order. #817’s coverage census, which reported all twelve as unreached by its lane, now reports ok: 131, under: 0 where it reported ok: 119, under: 12.

Every duplicate #include/#import in the bridge sources removed (#1385)

128 duplicate include directives across 36 Sources/OCCTBridge/src/*.mm files, first occurrence kept. Thirty-one came from the four merged .mm splits, whose migration script deduped the shared preamble by raw line text and so kept a header included once as #import and once as #include; the script was fixed when that was found, and this is the retroactive half. The other ninety-seven are pre-existing duplicates in five hand-written files (OCCTBridge.mm, OCCTBridge_ProjLib_NLPlate.mm, OCCTBridge_Properties.mm, OCCTBridge_BRepGraph.mm, OCCTBridge_AIS.mm), two of them holding a header three times. No behavior change: duplicate includes resolve through include guards.

docs/thread-safety.md’s #374 writeup corrected to the fix that actually shipped (#1400)

The Resource_Manager::Debug / Storage_Schema current-data section described the first version of patch 0016, an ICurrentDataMutex() recursive mutex around every touch point. That version was revised on upstream review: 0016 deletes the ICurrentData()/ISetCurrentData() statics and gives Storage_Schema a per-instance myCurrentData member instead, so there is no shared state left to guard. The suppression-policy paragraph also still named the #353 CDM_Application suppression as its current example, which tsan.supp dropped in v1.15.11 once patch 0015 landed; it now names the live TopoDS_TShape::myState / #1154 / 0030 entry. docs/occtswift-wrapping-gaps.md’s carve-out entry, which recorded both as filed-not-fixed, is updated to match.

CLAUDE.md moves its rules into okf/ and its Known OCCT Bugs record into okf/references/ (#1617)

CLAUDE.md shrinks from 1,072 lines to 345, keeping commands, guard syntax and a short working list, with one pointer per section into okf/. New: okf/references/known-occt-bugs.md (one row per root-caused kernel defect, with fix location and writeup pointer) and four okf/policies/ pages (pinned-kernel-patch-check, required-status-checks, static-gates, null-handle-guards). okf/references/carried-occt-patches.md gains rows for 0030, 0031 and 0033, the retired 0032, and a table of the five patches the pinned v3.0.0 asset does not hold. Scripts/census-comment-staleness.py’s patch-citation channel scans the two okf references as well as CLAUDE.md. Three stale claims fixed: #344/#345 are not “still uncharacterized”, gate-scripts is required on main not refactor/**, and no step says to commit a release directly.

EdgeAnalysis.checkVerticesWithCurve3d/checkVerticesWithPCurve default precision now matches OCCT’s own sentinel (#1577)

EdgeAnalysis.checkVerticesWithCurve3d/checkVerticesWithPCurve defaulted precision to a fixed 1e-6, stricter than ShapeAnalysis_Edge’s own documented sentinel default (a negative preci checks each vertex against its own stored tolerance instead of a fixed distance). For a vertex whose own tolerance is looser than 1e-6 (common on healed/mesh-derived geometry), the old default could flag a mismatch that OCCT’s own semantics does not consider one. Both functions now default precision to -1.0, matching OCCT. The bridge already forwarded the value unmodified; this is a pure Swift-side default change with no signature change.

LawFunction.bspline and .interpolated reject mismatched parallel arrays (#1586)

OCCTLawCreateBSpline reads multiplicities[i] once per knot and OCCTLawInterpolate reads parameters[i] once per value, neither checking that the paired array holds that many elements. The Swift entry points guarded only the minimum lengths, so a caller passing a shorter multiplicities or parameters read past the end of its own buffer.

LawFunction.bspline(poles:knots:multiplicities:degree:) now requires multiplicities.count == knots.count, and LawFunction.interpolated(values:parameters:periodic:) requires a non-nil parameters to match values. Both directions of mismatch are refused with nil, and both doc comments state the length contract the bridge relies on. No signature change.

Shape.threadedHole no longer cuts the internal thread at the wrong radius (#1578)

Bug fix, correctness-critical. threadedHole passed the major radius (spec.nominalDiameter / 2) as the internal cutter’s helixRadius. For an internal thread (a boolean subtraction), the cutter’s untouched “mouth” edge becomes the thread’s crest and its cutting “apex” edge (helixRadius + cutDepth) becomes the root (confirmed against OCCTShapeBuildThreadCutter’s own contract: apexR = helixRadius + apexSign*cutDepth). Passing the major radius put the crest at the major/nominal diameter and the root beyond it, backwards from standard thread geometry, where an internal thread’s crest sits at the minor diameter (mating a bolt’s own root) and the root reaches out to exactly the major/nominal diameter (mating a bolt’s own crest). A bolt (threadedShaft) and its tapped hole (threadedHole) built from the same ThreadSpec did not actually mate: the nut’s ridges sat at the bolt’s own crest radius instead of clearing it. Fixed by passing spec.minorDiameter / 2. threadedShaft’s external path is unaffected (its own nominalDiameter / 2 is correct there). New regression tests (Issue1578ThreadedHoleMinorDiameterTests) build a bolt and a matching tapped hole from the same spec and confirm the nut’s root matches the bolt’s own crest radius (they mate), and that the internal thread’s root lands at the nominal diameter rather than beyond it. Five existing tests that pre-bored their fixtures to the (buggy) major-diameter convention are updated to the physically-correct minor-diameter tap-drill size.

TObjApplication.shared now releases its OCCT refcount on deinit (#1588)

OCCTTObjApplicationGetInstance() incremented TObj_Application::GetInstance()’s singleton refcount on every .shared access with no matching release, leaking one increment per call (inert in practice, since the singleton’s own permanent static handle keeps it alive regardless, but a violation of this project’s “every creating function needs a matching Release” rule). Added OCCTTObjApplicationRelease and a matching TObjApplication.deinit. No public API signature change.

Fixed DXF LTYPE table’s declared entry count to match its actual entries (#1589)

DXFWriter.tables()’s LTYPE table header declared a group-70 max-entry count of 4 while only 3 linetypes (CONTINUOUS/DASHED/CHAIN) were ever written before ENDTAB, a stale value present since the file’s first commit. The declared count now matches the real entry count (3), consistent with every other table (LAYER, STYLE) in the same writer.

Shape.revolutionAxes(tolerance:)/Shape.symmetryAxes(fractionalTolerance:) no longer crash past 256/8 distinct axes (#1576)

OCCTShapeRevolutionAxes/OCCTShapeSymmetryAxes wrote only the capped number of entries into the caller’s output buffer but returned the full, uncapped count, so a shape with more than 256 distinct (post-dedup) revolution axes made Shape.revolutionAxes() trap with a fatal “Index out of range” error. Both bridge functions now return the count they actually wrote.

SVG export: Y-flip transform now reflects about the viewBox’s own midline, not just its height (#1570)

Fixed SVGWriter.write(to:)’s Y-flip transform, which was missing a vb.min.y term and so mapped content entirely outside the declared viewBox for any drawing not centered at the origin (the common no-explicit-viewBox Exporter.writeSVG(drawing:to:) path). Output coordinates now change for any SVG export whose computed or supplied viewBox has a non-zero min.y; content that previously rendered clipped/invisible under a spec-compliant SVG viewer (default root overflow: hidden) now renders correctly inside the declared viewBox.

FeatureReconstructor.absorbAdditive now surfaces a total union failure as Skipped instead of discarding accumulated geometry (#1585)

Fixed: when an id’d additive feature’s fusion into the accumulated shape totally failed (both the history-recording union and the plain union fallback), absorbAdditive silently replaced the accumulated shape with the new feature’s raw, unfused body, never recorded a Skipped entry, and still listed the feature as fulfilled. It now calls recordSkip and leaves the accumulated shape untouched, matching applyBoolean/applyHole/applyFillet/applyChamfer’s existing failure-path behavior and the documented FeatureReconstructor contract.

SheetMetal.Bend.angle’s sign now drives auto-inferred bend direction; doc corrections for two dead fields; intersect now checks both seam axes (#1565)

Fixed three doc/behavior mismatches in SheetMetal.swift found by the Pass 1 correctness sweep:

  • Bend.angle’s documented sign convention (positive = concave, negative = convex) is now actually honored: when a bend’s direction is left at its default .auto, a non-nil, non-zero angle overrides the geometric inference by its sign. Previously angle was accepted but silently ignored for direction resolution.
  • Bend.outsideRadius/materialThicknessAtBend are documented as forward-compatible but not yet read by Builder.build(); the docs previously (incorrectly) instructed callers to use them for an extruded-angle profile / thinned bend line, effects the Builder cannot currently produce.
  • intersect(bend:a:b:) now checks both a flange’s u AND v axes before falling back to “no split needed”; previously only u-alignment was checked, and a seam genuinely diagonal to both axes could trigger a bogus split attempt, including a spurious BuildError.nonRectangularStepFlange for a non-rectangular flange with no real stepped seam.

ViewObject.ProjectionType raw values now match XCAFView_ProjectionType (#1574)

ViewObject.ProjectionType’s raw values were central=0, parallel=1, which did not match the real XCAFView_ProjectionType (NoCamera=0, Parallel=1, Central=2); the bridge casts the raw value straight into the OCCT enum with no translation, so .central actually stored NoCamera, and real Central could never be set or read at all. Fixed: ProjectionType is now noCamera=0, parallel=1, central=2, with a new .noCamera case for OCCT’s own default/unset sentinel.

public enum ProjectionType: Int32 {
    case noCamera = 0
    case parallel = 1
    case central = 2
}

DriverTable.exists is documented as the creation query it is (#1587)

TPrsStd_DriverTable::Get() returns the static table and, per its own header, “if it does not exist, creates it and fills it with standard drivers”. The handle is therefore never null, DriverTable.exists always answers true, and reading the property is itself what creates and populates the process-wide table. TPrsStd_DriverTable declares no non-creating alternative, so there is nothing to switch the implementation to.

The Swift property, the bridge declaration and its definition now say this, and docs/reference/Document-XCAF-Notes.md drops an if DriverTable.exists { DriverTable.clear() } example whose false branch can never be taken. The property keeps its name: a rename would break source for a case the documentation settles. No behaviour change.

WireOrder.Status/decode no longer misreports a successful analysis as nil/.failed (#1575)

ShapeAnalysis_WireOrder::Status()’s real codes are 0=unchanged, 1=reordered, -1=reversed-but-connected, 3=shifted-still-connected, all four successful, with no “gaps” code. WireOrder.analyze(edges:)/analyze(wire:) used to return nil for the -1 case and report .failed (with orderedEdges still populated) for the 3 case. WireOrder.Status’s cases are renamed to match the real semantics: .closed/.open/.gaps/.failed become .unchanged/.reordered/.reversed/.shifted/.failed, and both analyze overloads now report a real, correctly-ordered WireOrder for all four codes.

OCCTPolyMergeNodes/mergedMeshNodes no longer mis-report counts on buffer overflow (#1566)

OCCTPolyMergeNodes (backing mergedMeshNodes(from:smoothAngle:mergeTolerance:)) used to set *outTriangleCount and return the merged node count unconditionally, even when the actual write into outVertices/outNormals/outIndices was skipped because a buffer was too small for the merged result. A caller trusting those counts got a MergedMeshData whose triangleCount/vertexCount disagreed with indices.count/vertices.count – or, on the vertex side, a hard Swift trap reading past the end of mergedMeshNodes’s own local buffer. Both are all-or-nothing failures now: OCCTPolyMergeNodes refuses the whole call (returns 0, *outTriangleCount left untouched) rather than reporting a count larger than what it actually wrote, and mergedMeshNodes gained a matching defensive check before trusting either count. Triggers only for meshes large enough to overflow the fixed 1,000,000-vertex/3,000,000-index buffers (realistically ~500K+ merged nodes); ordinary meshes are unaffected.

Edge.curve3D’s doc corrected: it returns the raw, untrimmed curve, not a Geom_TrimmedCurve (#1584)

The doc previously claimed the curve was trimmed to the edge’s own parameter range. It never was – the bridge deliberately returns the raw underlying Geom_Curve so callers can DownCast it to a concrete type (Geom_Circle, Geom_Line, …). Use Edge.parameterBounds for the edge’s own finite extent; curve3D.domain reports the underlying geometry’s full range (unbounded for a line, a full period for a circle/ellipse). Doc-only fix, no behavior change.

AssemblyNode.descendants(allLevels:)/.layers no longer silently truncate past their buffer caps (#1563)

OCCTDocumentGetDescendantLabels/OCCTDocumentGetLabelLayers used to return the count they had written, indistinguishable from a tree/label with exactly that many entries once the write was capped at 1024 descendants / 16 layer names, the pre-#562 shape. Both bridge functions now report the TRUE total count even when truncated, and descendants(allLevels:)/.layers retry once with a buffer sized to that count, so both Swift APIs return every entry instead of silently dropping the rest.

AssemblyGraph.NodeType’s raw values now match XCAFDoc_AssemblyGraph::NodeType exactly (#1568)

The enum’s raw values were scrambled against the pinned OCCT 8.0.1 header: a node OCCT reports as Occurrence (raw 3) decoded as .instance, and there was no way to correctly ask for OCCT’s real Part/Occurrence/AssemblyRoot through this API. Cases are renamed to match OCCT’s own semantics and raw values one-for-one: undefined=0, assemblyRoot=1, subassembly=2, occurrence=3, part=4, subshape=5 (previously node=0, occurrence=1, part=2, instance=3, subshape=4, free=5). No other Swift call site relied on the old values. Breaking change for any caller switching on a specific case or persisting the raw value.

Closes #1568

🤖 Generated with Claude Code

Fixed OOB reads in weightedCentroid/loadLinearXYZ on mismatched parallel-array lengths (#1583)

GeometryProperties.weightedCentroid(points:weights:) and PlateSolver.loadLinearXYZ(uvPoints:targets:coefficients:) now guard that their parallel caller-supplied arrays are the same length before forwarding to the bridge, returning a clean refusal ((0, nil) / false) on a mismatch instead of reading past the shorter array’s end.

IntAna.linePlane now distinguishes a disjoint parallel line from one embedded in the plane (#1582)

ConicQuadResult gains isInQuadric: Bool, forwarded from OCCT’s own IntAna_IntConicQuad::IsInQuadric(), which the bridge already computed but Swift never read. When isParallel is true, points/params are empty either way; isInQuadric now tells apart a line lying entirely within the plane (true) from one that is merely parallel and disjoint (false): two geometrically opposite outcomes that were previously indistinguishable. lineSphere(...) forwards the same field but it is always false there, since the bridge never calls IsInQuadric() for the line-sphere case.

Fixed DrawingAnnotation.surfaceFinish’s check-mark arms actually being symmetric (#1573)

The “Long arm”/”Short arm” comments described an asymmetric ISO 1302 tick mark, but both arms were computed as mirror images of the apex and were mathematically identical in length. The two arms now follow ISO 1302 Annex A’s proportions (long leg height ~2.1x the short leg’s, both at ~60° to the surface baseline); the .machiningRequired bar now caps the long arm only rather than connecting back to the (now shorter) short arm.

Fixed Color.toHex/toHexRGBA’s backwards, misnamed prefix parameter (#1571)

toHex(sRGB:)/toHexRGBA(sRGB:) are now toHex(includeHashPrefix:)/ toHexRGBA(includeHashPrefix: Bool = true). The old sRGB parameter was bridged to OCCT’s theToPrefixHash (the '#' prefix switch, not a linear/sRGB switch; that mode does not exist in Quantity_Color::ColorToHex/Quantity_ColorRGBA::ColorToHex in this OCCT version) and was negated on the way in, so the documented default silently returned a '#'-prefixed string and sRGB: true stripped it instead of converting anything. includeHashPrefix now does exactly what its name says, defaulting to true (matching OCCT’s own default), and the doc comments and docs/reference/Color-Material.md describe the real behavior.

let hex = Color.red.toHex()                              // "#FF0000"
let noPrefix = Color.red.toHex(includeHashPrefix: false)  // "FF0000"

Sheet.standardLayout’s inter-cell gap is now genuinely margin/2, matching its documented algorithm (#1572)

Sheet.standardLayout(of:scale:margin:includeIso:) produced a full-margin gap between cells instead of the documented margin/2: the column stride between cell centres baked in a full margin regardless of cell width. Fixed so the gap is genuinely margin/2; each cell is correspondingly margin/4 wider/taller than before, and view positions/fitScale shift for existing callers using non-default margins or margin-sensitive layouts.

AAG neighbor-lookup methods now guard against a negative face index (#1580)

AAG.neighbors(of:), AAG.edge(between:and:), AAG.concaveNeighbors(of:) and AAG.convexNeighbors(of:) used to guard only the upper index bound, so a negative faceIndex (or face1) reached an Array subscript and crashed the process (Fatal error: Index out of range) instead of returning the documented empty/nil result for an out-of-range index. Fixed by adding a lower-bound check to each guard.

PresentationStyle.toOCCT() no longer drops a curve-only color (#1569)

A PresentationStyle with only curveColor set (surfaceColor left nil) used to silently fall into toOCCT()’s empty-style branch, so isEmpty wrongly reported true and isEqual(to:) ignored the curve color whenever surface color was unset on either side. Added OCCTXCAFPrsStyleCreateWithCurvColor (bridge) and a matching toOCCT() branch (Swift) so a curve-only style now round-trips correctly.

Drawing.addCuttingPlaneLine refuses a zero-length normal or view direction (#1581)

The degenerate guard normalized both inputs before measuring them. simd_normalize(.zero) is a NaN vector and every comparison against NaN is false, so a zero-length cuttingPlaneNormal or viewDirection passed the guard, then passed the later fallback-axis checks on the same terms. The call returned a .cuttingPlaneLine annotation built from the hardcoded (1, 0)/(0, 1) fallback axes, or from NaN geometry when viewDirection was the zero vector, in place of the documented nil. Both inputs are now length-checked before anything normalizes them.

MedialAxis.init(of:) and addAutoCentermarks doc comments corrected to match actual (already-correct) behavior (#1579)

MedialAxis.init(of:) documented itself as using only the outer wire of the first face found; it actually uses all wires of that face (outer boundary and inner/hole wires alike), which is what its own thin-wall-detection use case relies on. Drawing.addAutoCentermarks’s doc stated the inverse of its actual, already-tested circle-visibility rule: a circle is visible when its plane normal is roughly parallel to the view direction, not when it isn’t. Doc-only; no behavior change.

The Quaternion Euler-order doc names the right ordinals (#1567)

setEulerAngles(order:alpha:beta:gamma:) documented 0 = Intrinsic_XYZ. In the pinned gp_EulerSequence.hxx, ordinal 0 is gp_EulerAngles, aliased Intrinsic_ZXZ and the classic Euler angles; Intrinsic_XYZ is ordinal 8. The doc comment now carries the full 26-value table, and getEulerAngles(order:), which had no order note at all despite sharing the parameter, points at it. Doc-only: the bridge passes order straight through to (gp_EulerSequence)order with no remapping, and the existing test already used order: 8 for Intrinsic_XYZ.

ConstructionPlane.throughAxis’s doc drops an adjacent face it never reads (#1564)

The case doc said the reference plane is “deduced from the first face adjacent to the axis”. The resolver calls perpendicularBasis(to:), a pure function of the direction vector using OCCT’s gp_Ax2 canonical smallest-component algorithm, and reaches no face and no BRepGraph on the way. docs/reference/Construction.md and Issue881PerpendicularBasisTests both already described the real behaviour; the inline comment predates #881’s basis unification. Doc-only.

Shape.encodingRegularity’s default tolerance was 172x stricter than OCCT’s own default (#1545)

encodingRegularity(toleranceDegrees:) defaulted to the bare literal 1e-10, taken as degrees. The bridge correctly converts this to radians before calling BRepLib::EncodeRegularity, whose own default (1.0e-10) is expressed in radians, so the old default converted to ~1.745e-12 radians, about 172x stricter than OCCT’s own default, meaning essentially no edge was ever marked “regular” at the default tolerance. The default is now 1.0e-10 * 180.0 / Double.pi (~5.7295779513e-9 degrees), which converts back to exactly OCCT’s own 1.0e-10 radian default.

Fix stale/wrong Document.swift doc comments for loadOBJ and documentCount (#1546)

loadOBJ(from:singlePrecision:systemLengthUnit:)’s doc comment no longer claims a nonexistent (default: false) for the required singlePrecision argument. documentCount’s doc comment no longer describes a shared, process-wide “application session”; since #371 every Document owns a private TDocStd_Application, so this property reports the count opened through that one instance (in practice 1 for a valid document, 0 for null). Same fix applied to docs/reference/Document-Persistence-IO.md. Documentation only, no behavior change.

Curve3D.bsplineKnotSequence() heap buffer overflow past ~1020 poles fixed (#1541)

OCCTCurve3DBSplineGetKnotSequence had no capacity parameter and unconditionally wrote every knot in a curve’s flat sequence into whatever buffer it was given; bsplineKnotSequence()’s fixed 1024-element Swift buffer overflowed for any BSpline whose real sequence length (poleCount + degree + 1, more for a periodic curve) exceeded that, a genuine out-of-bounds heap write. The bridge function now takes a maxCount capacity and clamps its writes, returning the number of knots actually written, matching OCCTCurve3DBSplineToBeziers/ OCCTSplitCurve3dContinuity’s existing pattern; the Swift wrapper sizes its buffer from the curve’s own pole count and degree (poleCount + 2*degree + 1, an exact upper bound for both periodic and non-periodic curves per Geom_BSplineCurve::KnotSequence’s documented length formula), so it never truncates.

Fixed Shape.loadSTEP(from:unitInMeters:)’s unit parameter being dead on arrival (#1548)

STEPControl_Reader::SetSystemLengthUnit() was called before ReadFile(), when it is a guarded no-op with no model yet to apply to (StepModel().IsNull() is always true at that point), so the imported shape always came back unscaled regardless of unitInMeters. Fixed by reordering the call to run after ReadFile() and before the transfer, and by converting unitInMeters (meters, matching the parameter name and the doc’s own examples) to SetSystemLengthUnit’s actual millimeter-based convention (empirically confirmed: 1.0 == 1 mm, 1000.0 == 1 m, 25.4 == 1 inch) before handing it off, so the doc’s existing examples (0.001 for mm, 0.0254 for inch) now work as documented rather than needing to change.

Curve2D.isLinear(tolerance:) can never return the documented nil, and bsplineMovePointAndTangent’s poleRange mislabeled OCCT’s independent continuity codes (#1542)

Curve2D.isLinear(tolerance:) is documented to return nil for a curve that isn’t a BSpline, but the bridge returned false for both “not a BSpline” and “genuinely non-linear (0.0 deviation)”, so the documented nil path was unreachable and a non-BSpline curve silently read back as (isLinear: false, deviation: 0.0). The bridge now signals the two cases distinctly, and the Swift wrapper maps “not a BSpline” to nil.

Curve2D.bsplineMovePointAndTangent/Curve3D.bsplineMovePointAndTangent took a poleRange: ClosedRange<Int>, forwarding it to Geom(2d)_BSplineCurve::MovePointAndTangent’s StartingCondition/EndingCondition parameters, two independent continuity codes (-1 free to move, 0 fixed, 1 fixed with tangent, and so on), not a pole-index range and not required to be ordered, so ClosedRange<Int> could not even represent a legitimate call like StartingCondition: 1, EndingCondition: -1 (1...(-1) traps at runtime). Both methods now take startingCondition: Int, endingCondition: Int directly.

distanceSS(to:deflection:)’s default deflection was 9 orders of magnitude off OCCT’s own default, silently returning the wrong extremum’s points (#1544)

Shape.distanceSS(to:deflection:) defaulted deflection to 100.0. Per OCCT’s own BRepExtrema_DistanceSS header, that parameter is a numeric-tie tolerance (default Precision::Confusion(), ~1e-7) controlling how many near-minimum extrema get folded into the solution set, not a spatial search radius. At the old default, .distance correctly reported the true minimum while .point1/.point2 (the first-appended solution) could silently describe a different, non-minimal extremum, with .solutionCount inflated by every extremum within 100 units of the minimum. The default is now 1e-7 (Precision::Confusion()), matching OCCT’s own default.

Shape.isSelfIntersecting(hardTimeout:) no longer falls back to a racy self-based check when deepCopy fails (#1549)

If Shape.deepCopy failed while building the geometry-independent probe isSelfIntersecting(hardTimeout:) uses, it silently fell back to running the check against self directly, reintroducing the shared Geom_Surface/Geom_Curve evaluation-cache race (#1160/#831) the probe exists to avoid. It now returns nil (indeterminate) instead, matching the method’s existing contract for every other case it cannot conclusively answer. No signature change.

Surface.extrema(to:) now uses each surface’s real domain when uvBounds1/uvBounds2 is omitted, not [0,1]x[0,1] (#1543)

Surface.extrema(to:uvBounds1:uvBounds2:) hardcoded a (0, 1, 0, 1) fallback for an omitted uvBounds1/uvBounds2, contradicting its own doc comment (“Uses full surface bounds if nil”). For any surface whose real domain isn’t [0,1]x[0,1] (a sphere, a full cylinder, an unbounded plane, …) this silently searched only a tiny corner of parameter space, giving a wrong nearest-point/distance or nil where a real extremum exists outside that corner. Now defaults to self.domain/other.domain, each surface’s actual parameter bounds. Also fixes two doc-only mistakes: toBezierPatchGrid()’s and Shape.surface(poles:)’s doc comments said “row-major order (U varies faster/fastest)”, backwards from the real loop order (V varies faster); and a gaussianCurvature(atU:v:) doc example called a nonexistent Surface.cylinder(axis:direction:radius:) overload instead of the real cylinder(origin:axis:radius:).

Shape.proximityFaces indices now address the same enumeration face(at:) does (#1550)

Shape.proximityFaces(with:tolerance:deflection:)’s returned face1Index/face2Index used to come from BRepExtrema_ShapeProximity’s own internal, non-deduplicated occurrence walk, rather than the deduplicated enumeration Shape.face(at:) reads. For a shape with a genuinely shared/instanced face occurrence (e.g. a split/Boolean result whose pieces share the cut face), proximityFaces’ indices could silently name a different face than face(at:) at that same index. Fixed by remapping through the same deduplicated enumeration face(at:) uses, matching #541’s convention for every other index-returning entry point in the bridge. The returned index values may now differ from before on shapes with shared face occurrences; ordinary shapes are unaffected.

isBooleanValidWith’s operation-code doc table was wrong; default corrected to BOPAlgo_UNKNOWN (#1540)

Shape.isBooleanValidWith(_:operation:testSmallEdges:testSelfInterference:) documented operation as 0=unknown, 1=common, 2=fuse, 3=cut, 4=section. The real mapping (the bridge casts straight through to BOPAlgo_Operation with no translation) is 0=common, 1=fuse, 2=cut, 3=cut21, 4=section, 5=unknown. The doc table is corrected, and the default changes from 0 (previously, silently, BOPAlgo_COMMON) to 5 (BOPAlgo_UNKNOWN), matching the generic-check semantics isValidForBoolean(with:) already documented. A caller passing an explicit operation: value should re-check it against the corrected table; a caller relying on the default should confirm the generic unknown-operation check (no operation-specific dimension-compatibility test) is still what they want.

meshFaceActiveTriangulationRepId/meshEdgePolygon3DRepId now consult the persistent mesh tier they promise (#1547)

Fixed BRepGraph.meshFaceActiveTriangulationRepId(_:) and meshEdgePolygon3DRepId(_:), which documented cache-first/persistent-fallback semantics but only ever queried the algorithm-derived mesh cache. For a face or edge whose triangulation/polygon-3D data lives only in the persistent (def-resident) tier (e.g. a shape meshed, or STEP-imported with an existing triangulation, before the BRepGraph was built), both accessors silently returned nil even though the sibling faceHasTriangulation/edgeHasPolygon3D correctly reported the data was present. Both now query Mesh().Effective(), matching faceHasTriangulation/edgeHasPolygon3D’s existing pattern.

Face.principalCurvatures(atU:v:)/Surface.principalCurvatures(atU:v:) now return dirMin/dirMax correctly paired (#1437)

Previously returned the maximum-curvature direction as dirMin and the minimum-curvature direction as dirMax, exactly transposed. Fixed to match GeomLProp_SLProps::CurvatureDirections’s real parameter order.

Shape.fixSmallCurves/fixSmallBezierCurves removed as non-functional no-ops; Shape.dividedByNumber actually divides now (#1491)

OCCTShapeUpgradeFixSmallCurves/OCCTShapeUpgradeFixSmallBezierCurves (and the Swift Shape.fixSmallCurves(tolerance:)/fixSmallBezierCurves(tolerance:) wrappers) always handed back the caller’s own unmodified shape: ShapeUpgrade_FixSmallCurves/FixSmallBezierCurves have no standalone Perform()/Compute() at all, and the only two real OCCT callers either need an active curve-split criterion these entry points never took, or are already exercised correctly via Shape.convertToBezier(). Removed both rather than leaving a no-op in place; use Shape.fixSmallEdges(tolerance:dropSmall:limitAngle:) instead, the real standalone equivalent, which already exists.

Separately, OCCTShapeDivideByNumber(shape, nbU, nbV) never called SetNumbersUVSplits(nbU, nbV), so per-axis split counts were silently ignored in favor of an auto-derived “roughly square” split (nbU=5,nbV=1 and nbU=1,nbV=5 produced identical results). Fixed by calling SetNumbersUVSplits. Also fixed a second, more severe defect found while writing a regression test for the first: MaxArea() was never set to the -1 sentinel Perform() needs to derive a max-area-per-part from NbParts() at all, so the function returned nil for every input, unconditionally, regardless of the U/V bug. Shape.dividedByNumber(_:) now actually works, and (since it always passes nbV: 1) splits a face into strips along its longer parametric axis rather than a grid; its doc comment is corrected to match.

occtCPntsUniformDeflectionImpl and 25 further split-statement cast sites gain a null-shape guard (#1513)

Shape.uniformDeflection(_:)/(_:range:) crashed the whole process (uncatchable SIGSEGV) on a Shape whose wrapper was non-null but whose underlying TopoDS_Shape was null (e.g. someShape.nullified?.uniformDeflection(_:)), instead of returning nil. The gap was a TopoDS:: cast assigned to a local in one statement, then consumed by a shape-dereferencing constructor or BRep_Tool:: call in the next, invisible to check-null-handle-guards.py’s outward walk, which only follows call-nesting within one expression. Fixed with occtShapeIsPresent(shape) at all 26 sites the newly-extended gate found across 10 files. No public API change.

Curve2DGcc.hatch no longer silently drops clockwise-wound boundaries (#1496)

OCCTCurve2DHatch added every boundary element to the oriented Geom2dHatch_Hatcher with a hardcoded TopAbs_FORWARD, so a boundary wound clockwise had its inside/outside swapped and every resulting hatch domain was silently dropped, IsDone still reporting success. The bridge now derives the whole boundary’s winding sense from its actual geometry (a shoelace sum over the ordered curve array) and orients every element TopAbs_REVERSED when it winds clockwise, matching how DBRep_IsoBuilder, OCCT’s own reference caller of this class, derives orientation from real edge orientation. Curve2DGcc.hatch(boundaries:...) now accepts a boundary wound in either direction with no change to its signature.

OCCTSewingNbMultipleEdges/OCCTSewingIsMultipleEdge null-handle guards (#1507)

OCCTSewingNbMultipleEdges and OCCTSewingIsMultipleEdge (OCCTBridge_Modeling_HealingSewing.mm) now guard a null OCCTSewingRef with if (!sewing) return <fallback>;, matching the file’s other 20 OCCTSewing* functions. A null sewing previously dereferenced a raw pointer uncatchably (SIGSEGV). Not reachable through the public Swift API (SewingBuilder always holds a non-null ref); hardening for the public C ABI.

Fixed IntAna.planeSphere returning (0, 0, 0) for the common secant case, and QuadricIntersection.coneSpherePoints NaN on sampleCount: 1 (#1495)

IntAna.planeSphere’s ordinary secant case (a plane cutting through a sphere) used to report a circle intersection’s center as (0, 0, 0) regardless of the real geometry: OCCT’s own IntAna_QuadQuadGeo::Point() only handles the tangent case and falls back to the origin for IntAna_Circle, and the bridge called it unconditionally. IntAna.QuadQuadResult gains resultType (mirrors IntAna_ResultType) and circles (center/axis/radius); the bridge now reads Circle() instead of Point() when resultType == .circle:

let r = IntAna.planeSphere(planeOrigin: SIMD3(0, 0, 3), planeNormal: SIMD3(0, 0, 1),
                            sphereCenter: .zero, sphereAxis: SIMD3(0, 0, 1), radius: 10)
// r.resultType == .circle, r.circles[0].center ≈ (0, 0, 3), r.circles[0].radius ≈ 9.539

Separately, QuadricIntersection.coneSpherePoints(sampleCount: 1) divided by zero (nbSamples - 1 == 0) and returned a single NaN point while still reporting success, even though sampleCount: 1 is documented as legal. It now returns the curve’s domain start instead.

GeomTools_{Curve,Curve2d,Surface}Set write functions refuse a batch with a duplicate handle instead of silently dropping it (#1512)

OCCTGeomToolsCurveSetWrite/Curve2dSetWrite/SurfaceSetWrite discarded GeomTools_*Set::Add()’s return value. Add() returns the index of the key, new or existing, deduplicating by underlying-object identity, so two array elements sharing one underlying Geom_Curve/Geom2d_Curve/Geom_Surface (e.g. the same Curve3D/Curve2D/Surface instance appearing twice, or two edges of a real BREP shape sharing one 3D curve) were silently collapsed to a single stored entry: Write() emitted a set with fewer entries than the caller passed, and deserializeCurves/deserializeSurfaces’s read-back array was shorter than what was serialized, with index correspondence silently broken from the first duplicate onward. All three write loops now refuse the whole batch (return nil) on a duplicate, matching the existing null-handle refusal’s own idiom.

Shape.analyticCurvaturePoints no longer swaps MinCur/MaxCur for ellipses; Curve2D.curveType’s null-handle fallback returns OtherCurve, not OffsetCurve (#1511)

Two bugs fixed in OCCTBridge_Geom2d_Adaptor.mm:

  • Shape.analyticCurvaturePoints(curveType: 2, ...) (ellipses) previously reported .maximumCurvature at the major-axis vertices (θ=0,π) and .minimumCurvature at the minor-axis vertices (θ=π/2,3π/2): backwards, per LProp_CurAndInf’s own radius-of-curvature definition. Now correctly reports .minimumCurvature at θ=0,π and .maximumCurvature at θ=π/2,3π/2.
  • OCCTCurve2DCurveType’s null/invalid-handle fallback returned 7 (GeomAbs_OffsetCurve) instead of 8 (GeomAbs_OtherCurve), matching the fix already shipped for the 3D sibling in #1476. Low reachability through the public Swift API today (Curve2D’s curve parameter is _Nonnull).

IGES Robust importers’ precision override was dead code; multi-shape IGES export silently dropped a shape AddShape rejected (#1504)

OCCTImportIGESRobust/OCCTImportIGESRobustProgress set read.precision.mode to 0 (File) and then set read.precision.val, which only takes effect under mode 1 (User); the value was never read. Now matches the STEP robust importers’ own shape: read.precision.mode stays File, and read.maxprecision.val is tightened from the default 1.0 to 0.1, capping how far healing may widen a tolerance to reconcile a defect.

Exporter.writeIGES(shapes:to:) (backed by OCCTExportIGESMultiShape) could silently drop a shape from the written file while still reporting success: IGESControl_Writer::AddShape can reject a shape that is topologically valid (BRepCheck_Analyzer-clean) but not IGES-translatable (for example a standalone degenerate edge with no 3D curve), and the bridge ignored that signal. The whole export now fails (ExportError.exportFailed) if any shape in the batch is rejected, rather than writing a file quietly missing part of what was asked for.

Seven Topology_Adjacency.mm functions guard against a null OCCTShapeRef (#1492)

OCCTEdgeFaceAdjacency, OCCTVertexEdgeAdjacency, OCCTEdgeAdjacentFaces, OCCTVertexAdjacentEdges, OCCTWireExplorerOrientations, OCCTWireExplorerVertices and OCCTShapeTransformIsNegative now guard a genuinely-null OCCTShapeRef the same way dozens of sibling functions in the same file already did, returning the same fallback each function’s existing catch (...) already produces instead of dereferencing a null pointer. Not reachable through the public Swift API today (Shape.handle is non-optional), so this is hardening with no observable behavior change for any currently-reachable input.

GeomFill_Darboux no longer crashes; surface-surface extrema now return both points’ full UV (#1502)

Shape.darbouxTrihedron(onFace:at:) used to hand GeomFill_Darboux::SetCurve a plain BRepAdaptor_Curve, where the class unconditionally static_casts the handle to Adaptor3d_CurveOnSurface* and reads private fields at offsets that don’t exist on that type: an uncatchable bus error on any real edge-on-face input. It now builds a genuine curve-on-surface from the edge’s pcurve on the face, so the operation works instead of crashing.

Surface.extremaSSPoint(other:index:) used to discard the V parameter of both returned points (Extrema_POnSurf::Parameter was called but only U was kept), reusing Curve3D.ExtremaPointPair, a struct designed for one parameter per point. Breaking: it now returns Surface.ExtremaSurfacePointPair, with explicit u1/v1/u2/v2 fields for both points, so a caller can actually re-locate a surface-surface extremum in either surface’s own UV space.

Surface.conversionGap deprecated: it never reflected convertToPeriodic(), and never can (#1510)

OCCTSurfaceConversionGap used to construct a throwaway ShapeCustom_Surface, run an unrelated ConvertToAnalytical(1e-3, Standard_False) recognition pass at a hardcoded tolerance the caller never supplied, discard the recognized surface, and return whatever Gap() held from that call. ShapeCustom_Surface::Gap()’s own header doc says it reports the deviation from the last call to ConvertToAnalytical, never ConvertToPeriodic, and myGap is written even on ConvertToAnalytical’s rejection path, so the old code could report a nonzero “gap” even when nothing was actually converted, and identically whether or not convertToPeriodic() had ever run.

Investigated whether ConvertToPeriodic could report a real measured deviation instead: it can’t, meaningfully. Reading its OCCT source shows it is a pure knot rearrangement (Geom_BSplineSurface::SetUPeriodic/SetVPeriodic, reusing the surface’s own poles), and direct-sampling reproducers (Scripts/repro/1510-surface-conversion-gap/) measured exactly 0.0 deviation between the original and periodic-converted surface in every scenario tried, including a deliberately not-quite-exactly-closed input. Surface.conversionGap is now @available(*, deprecated, ...) and its bridge function always returns -1.0, a documented sentinel rather than a fabricated measurement; convertToPeriodic()’s own doc comment explains why there is no accompanying gap. Existing callers keep compiling, with a deprecation warning.

Fixed roughness-from-shininess formula and color attribute round-trip in OCCTBridge_Document_Appearance.mm (#1508)

OCCTDocumentGetLabelMaterial’s HasCommonMaterial() fallback divided Shininess (already [0,1]) by 100 before inverting it, collapsing every legitimate roughness estimate into [0.99, 1.0]; it now reuses OCCTMaterialRoughnessFromSpecular, the physically-based specular-color+shininess conversion already used elsewhere. OCCTDocumentSetColorAttr/SetColorRGBAAttr built their Quantity_Color with Quantity_TOC_sRGB while the matching getters read back with no colorspace conversion, so a color set via AssemblyNode.setColorAttribute did not round-trip through colorAttribute/colorRGBAAttribute; both setters now use Quantity_TOC_RGB, matching the sibling setColor/color pair.

OCCTMedialAxisDistanceOnArc linearly interpolated the inscribed-circle radius instead of measuring the real curve point (#1493)

OCCTMedialAxisDistanceOnArc computed the boundary distance only at an arc’s two endpoint nodes and linearly interpolated between them for any intermediate t, instead of evaluating the arc’s own trimmed curve at t and measuring distanceToBoundary from that real point, the mechanism OCCTMedialAxisDrawArc already used two functions above it in the same file. Linear interpolation of distance-to-boundary is exact only when the bisector arc is a straight line; most arcs are circular (corner-corner bisectors) or parabolic (vertex-to-edge bisectors from a reflex corner), where the true inscribed-circle radius is not linear in t, up to 30% wrong at an arc’s own midpoint on a reflex-vertex polygon. Fixed to evaluate the trimmed curve’s own D0 at the requested t (respecting GeomBis’s Reverse flag so t=0/t=1 still land on the arc’s first/second node exactly as documented) and measure the real distance from that point; falls back to the old node-position interpolation only if the curve can’t be evaluated. Bridge-side fix only, no OCCT kernel patch. MedialAxisRectangleTests.rectangleDistanceOnArc is unaffected: a rectangle’s bisector arcs are all straight lines, the one shape family where linear interpolation happens to be exact.

.visibleIso/.hiddenIso/.isoLine HLR edge categories always returned nil (#1500)

OCCTHLRGetEdgesByCategory and OCCTHLRCompoundOfEdges built their HLRBRep_Algo with algo->Add(shape->shape), omitting the nbIso argument that OCCT defaults to 0. HLRTopoBRep_DSFiller::Insert gates isoline computation entirely on that count, so hlrEdges(direction:category:)’s .visibleIso/.hiddenIso and hlrCompoundOfEdges(direction:edgeType:visible:in3d:)’s .isoLine always returned nil, contradicting the bridge’s own “exact HLR only” documentation. Both functions gain a new nbIso: Int = 10 parameter (hlrEdges(direction:category:nbIso:) / hlrCompoundOfEdges(direction:edgeType:visible:in3d:nbIso:)), matching the precedent hlrPolyEdges(...:deflection:) already sets, and the bridge now passes it through to algo->Add(shape, nbIso). Existing call sites are unaffected: the new parameter defaults to 10, the same value the issue’s own reproducer used to confirm the fix.

OCCTGeomFillCoonsAlgPatchEval reparametrizes boundaries to [0,1] before evaluating (#1499)

Shape.coonsAlgPatch(edge1:edge2:edge3:edge4:evalU:evalV:) built each boundary from the edge’s own raw curve parameter range and never reparametrized it to [0,1] before evaluating the resulting patch at u,v normalized to [0,1], so for real (non-[0,1]-parametrized) edges it silently sampled only a tiny sliver of the true patch near each edge’s own parameter origin, instead of the requested point. Fixed by reparametrizing each boundary to [0,1] first, matching GeomFill_ConstrainedFilling’s own established usage.

Fixed canonical-shape recognition falling back to Plane/Unrecognized (#1509)

OCCTShapeRecognizeCanonical, OCCTShapeRecognizeCanonicalSurface, and OCCTShapeRecognizeCanonicalCurve ran their sequential IsPlane/IsCylinder/IsCone/IsSphere/ IsLine/IsCircle/IsEllipse checks on one ShapeAnalysis_CanonicalRecognition instance with no ClearStatus() between them. myStatus is set to 1 on an ordinary “not this type” outcome, not just a genuine error, and every IsX short-circuits to false while myStatus != 0, so a cylindrical, conical, or spherical face (and a straight-line edge, through the combined whole-shape entry point) was never actually recognized past the first check; Shape.recognizeCanonical() and Shape.recognizeCanonicalSurface() returned .plane/nil/.none instead of .cylinder/.cone/ .sphere/.line. Fixed by clearing the recognizer’s status between checks.

occtFillingAddConstraint now degrades to position-only continuity for a pcurve-less edge instead of failing the whole fill (#1503)

Shape.fill(boundaries:supportedBy:parameters:) and Shape.fill(constraints:parameters:) (and their bridge counterparts OCCTShapeFillWithSupport/OCCTShapeFillConstraints, plus FillingSurface’s add(edge:continuity:)/add(edge:support:continuity:)) used to return nil for the whole fill when a boundary edge had neither a nominated/inferred support face nor a pcurve of its own, at any continuity above .c0: OCCT’s face-less fill overload throws for that case, and the bridge never caught it by downgrading first. Fixed to degrade that edge to position-only continuity instead, matching the documented per-edge fallback. Shape.fill(boundaries:parameters:) (no support) is affected the same way, since all these entry points share one bridge helper; its own doc comment, which previously promised the opposite (whole-call failure), is corrected to match.

Five null-handle guards fixed in OCCTBridge_Modeling_SolidPrimitives.mm (#1498)

occtShapePeriodicImpl (Shape.makePeriodic/Shape.repeated), OCCTShapeMakeDraft (Shape.draft), OCCTShapeCreateRevolution (Shape.revolve(profile:...)), OCCTShapeCreateRevolutionFull and OCCTShapeCreateRevolutionPartial (Shape.revolved(...)) checked only the wrapper pointer, not the geometry it wraps, before passing it into OCCT. A non-null wrapper carrying a null shape/wire – reachable via the deprecated Shape.nullified property – crashed the process instead of returning nil. All five now use occtShapeIsPresent(...), matching this file’s other shape-consuming functions.

Shape.facesFromEdges no longer discards every face when the input has more than one disjoint closed loop (#1506)

Fixed a total-data-loss bug in OCCTShapeEdgesToFaces: a compound containing more than one disjoint closed edge loop (e.g. two separate closed squares) threw an uncaught StdFail_NotDone while trying to reconnect the second loop’s first edge, discarding every already-built face and returning nil for the whole call. Also fixed a related defect in the same recovery branch where the edge that failed to connect was silently dropped instead of seeding the next wire, which would have caused the second loop to come up one edge short once the exception was fixed. Shape.facesFromEdges(_:onlyPlanar:) now correctly returns one face per disjoint closed loop.

ExtremaElC.lineToCircle/.circleToCircle/.lineToEllipse no longer return an empty array for a degenerate parallel case (#1501)

OCCTExtremaElCLinCirc, OCCTExtremaElCCircCirc, and OCCTExtremaElCLinElips discarded a genuinely computed distance whenever Extrema_ExtElC reported IsParallel() (a line coincident with a circle’s or ellipse’s own axis, or two coaxial, coplanar circles), returning an empty Swift array instead of the well-defined constant distance OCCT had already computed. Fixed to retrieve ext.SquareDistance(1) and return one result, matching the existing ExtremaElC.lineToLine pattern for its own parallel case.

Extrema2d.distanceBetweenLines returns a genuine matched point pair for parallel lines (#1494)

Fixed OCCTExtremaExtElC2dLinLin’s parallel-lines branch, which used to echo each line’s raw input origin as the returned “closest” point pair. Those origins only achieved the reported distance when the offset between the lines happened to already be perpendicular to their shared direction; in general they didn’t, so Extrema2DResult.point1/.point2 could be reported alongside a distance they did not actually achieve. Now derived by projecting line 1’s origin perpendicularly onto line 2, a genuine matched pair for any pair of parallel lines.

occtHasSelfIntersectingWire now detects self-intersection on a bare wire, not just a face (#1505)

The #263 crash guard behind Shape.extruded(by:), Shape.healed() and Shape.healedWithFullHistory() only ever checked self-intersection when its input already was (or contained) a TopoDS_Face, because BRepCheck_Wire::SelfIntersect() needs a face context to project pcurves onto. A bare-wire Shape (e.g. from Shape.fromWire(_:)) silently passed the guard even when genuinely self-intersecting. Fixed by synthesizing a planar face per face-less wire before checking; a wire that can’t be faced falls back to no verdict rather than crashing. No public API change.

Fixed two uncatchable crashes on a null shape in ShapeFix healing operations (#1479)

OCCTShapeFixComposeShell (backing Shape.composeShell(precision:)) now guards against a non-null wrapper carrying a null TopoDS_Shape (e.g. the deprecated Shape.nullified), and OCCTShapeFixEdgeConnect (backing Shape.fixEdgeConnect()) now guards against a genuinely-null OCCTShapeRef, matching its sibling OCCTShapeConnectEdges. Both previously crashed the process (SIGSEGV) uncatchably; both now return nil.

Wire.filletedAll2D/chamferedAll2D no longer mask a mid-loop failure, and chamfer adjacency now follows true wire connection order (#1478)

Wire.filletedAll2D(radius:) and Wire.chamferedAll2D(distance:) used to check ChFi2d_Builder::Status() only once, after their whole per-vertex/per-edge-pair loop; since that field is overwritten by every AddFillet/AddChamfer call, a failure earlier in the batch, masked by a later success, could silently produce a wrong partial result instead of the documented “original wire if some failed” fallback. Both now track failure across the whole loop correctly.

Wire.chamferedAll2D(distance:)’s adjacency pairing also assumed TopExp::MapShapes returns edges in wire-connection order; for a wire whose edges were added out of sequence (e.g. via Wire.wireFromEdges), that assumption could pair the wrong edges and skip real adjacent corners entirely. It now derives adjacency from BRepTools_WireExplorer, the wire’s true connection order.

occtDocumentCreateDimensionImpl no longer double-registers a single-shape dimension (#1481)

A single-shape dimension created via Document.createDimension(on:type:value:...) / createDimension(on:type:value:lowerTolerance:upperTolerance:) used to be registered under both XCAFDoc_DimTolTool’s DimensionRefFirstGUID and DimensionRefSecondGUID graph-node roles for its shape, so XCAFDoc_DimTolTool::GetRefDimensionLabels reported it twice for that shape, silently corrupting any consumer that counts or deduplicates dimensions per shape (STEP export, UI listings, etc.). The live defect was in OCCTBridge_Document_DocumentLifecycle.mm’s occtDocumentCreateDimensionImpl, which now calls XCAFDoc_DimTolTool’s dedicated single-shape SetDimension(theL, theDimTolL) overload instead of the 3-sequence one with a duplicated argument.

Adds Document.refDimensionCount(for:) to read how many dimensions reference a shape:

let label = doc.addShape(shaft, makeAssembly: false)
doc.createDimension(on: label, type: .sizeDiameter, value: 20.0)
print(doc.refDimensionCount(for: label)) // 1, not 2

Curve2D.approxWithDetails reports the maxError approximated discarded (#1474)

OCCTCurve2DApproximate gated its result on Geom2dConvert_ApproxCurve::HasResult() alone, never reading MaxError() or IsDone(). OCCT documents HasResult() as true even for a fit that is “not NECESSARILY within the required tolerance”, so Curve2D.approximated could silently hand back a curve nowhere near the requested tolerance with no way for a caller to tell.

Curve3D and Surface had the identical trap and were fixed by #491, adding an approxWithDetails companion sharing one implementation with the plain entry point. Curve2D.approximated was left out of that fix: Geom2dConvert_ApproxCurve appeared exactly once in the whole bridge, at this one call site, with no detailed sibling.

Curve2D now gets the same companion, matching the #491 pattern: Curve2D.approxWithDetails(tolerance:continuity:maxSegments:maxDegree:) (backed by a new OCCTGeomConvertApproxCurve2D/OCCTApproxCurve2DResult) returns the same fitted curve approximated does, plus the maxError/isDone/hasResult diagnostics OCCT already computes for it. Both entry points now share one bridge helper (occtApproxCurve2D), so they cannot drift the way the pre-#491 Curve3D/Surface pairs did. Curve2D.approximated’s behavior is unchanged: it still returns a best-effort fit whenever HasResult() is true, matching Curve3D.approximated/Surface.approximated; its doc comment now carries the same caveat those two already do.

New Issue1474Curve2DApproxDetailsTests (Tests/OCCTGeom2dTests) proves approxWithDetails surfaces the gap: a single degree-3 segment fitted to a full radius-10 circle against a 1e-9 tolerance still reports hasResult/a non-nil curve (matching approximated’s existing behavior), but maxError measures 5.108514..., three orders of magnitude past the tolerance, matching the analogous Curve3D starved-fit case’s ~5.1 almost exactly, information OCCTCurve2DApproximate discarded entirely before this fix.

Fixed a corrupted identity-matrix fallback and documented explorerIsAssembly’s always-false contract (#1480)

  • Document.explorerLocation(at:) returned a corrupted “identity” matrix (spurious Z=1 and a y += z shear) for an out-of-range index or on an internal exception, instead of a true identity. Fixed.
  • Document.explorerIsAssembly(at:) always returns false: it shares its flat index with explorerShape(at:)/explorerDepth(at:)/explorerLocation(at:), all built by walking leaf nodes only, which structurally excludes every assembly node. This is by design, not a bug; the documentation previously implied otherwise. Use AssemblyNode.isAssembly (via Document.node(at:)) to detect an assembly.

Shape.fastSewn(tolerance:) no longer wraps an empty result as success (#1475)

OCCTShapeFastSewn never checked BRepBuilderAPI_FastSewing’s output for null before wrapping and returning it as success. BRepBuilderAPI_FastSewing only handles naturally-bounded surfaces (a sphere, cylinder, cone, torus); an ordinary planar TopoDS_Face (the common shape of a box, or any other polyhedral B-Rep solid) is declined outright, so Add() returns false for every face, Perform() finds nothing, and GetResult() is a null TopoDS_Shape. fastSewn(tolerance:) was wrapping that null shape in a non-nil Shape, contradicting its own doc (“Returns: The sewn shape, or nil on failure”). Now returns nil for those cases, matching sibling sewing entry points in the same file. sewn(tolerance:) is the general-purpose sibling for shapes with trimmed/analytic faces.

Fixed two silent-failure defects in OCCTBridge_Geom2d_Curves.mm (#1477)

OCCTGeom2dConvertApproxArcsSegments returned the unclipped OCCT-side piece count instead of the number of curves actually written into the caller’s buffer, so Curve2D.approxArcsAndSegments(tolerance:angleTolerance:)’s fixed 256-slot buffer could read out of bounds when approximation needed more pieces than that. OCCTCurve2DJoinToBSpline discarded Geom2dConvert_CompCurveToBSplineCurve::Add’s bool return, so Curve2D.join(_:tolerance:) silently dropped any curve that failed to attach (out of order, gapped, or outside tolerance) instead of failing the join. Both now match the pattern their sibling functions in the same file already use.

OCCTCurve3DCurveType’s null/exception fallback returns OtherCurve (8), not OffsetCurve (7) (#1476)

Curve3D.curveType’s fallback for a null/invalid handle or any caught exception returned 7 (GeomAbs_OffsetCurve), a specific, real, wrong curve type, instead of 8 (GeomAbs_OtherCurve, “other/unknown”). Two doc comments (OCCTBridge_Curve3D.h, Curve3D.swift) repeated the same off-by-one in their enumerated value list; both corrected.

Fixed an uncatchable crash in TransformFactory2D.mirrorAxis(point:direction:) on a zero-length direction (#1473)

OCCTMakeMirror2dAxis now guards the gp_Dir2d construction with try/catch, matching every sibling transform-factory function in the same file. A zero-or-near-zero direction now returns a zeroed transform matrix instead of aborting the process.

OCCTBoundSortBoxCompare returned OCCT’s native 1-based indices and silently truncated past maxIndices (#1462)

OCCTBoundSortBoxCreate stores caller box i (0-based) at OCCT array position i+1, but OCCTBoundSortBoxCompare copied Bnd_BoundSortBox::Compare()’s raw 1-based OCCT indices straight into outIndices with no -1 translation, so BoundSortBox.compare(...) returned the wrong box (or, for the last box, an out-of-bounds index) instead of the caller’s own 0-based indices. The same function also silently truncated past maxIndices, returning the number written rather than the true number of intersecting boxes, so a query hitting more than BoundSortBox’s fixed 1000-element buffer lost the rest with no signal. Both are fixed: indices are translated back to 0-based, and the bridge function now follows the codebase’s count-then-fill convention (outIndices=NULL is a sizing query; a fill call always returns the true count, so return value > maxIndices signals truncation), and BoundSortBox.compare(...) sizes its buffer from that count instead of a fixed 1000-slot array, so it can no longer truncate.

Shape.fillet2dEdges no longer hardcodes the fillet plane’s origin to world (0,0,0) (#1459)

OCCTChFi2dFilletEdges built the gp_Pln passed to ChFi2d_FilletAPI from the caller’s plane normal alone, always at gp_Pnt(0, 0, 0). Both algorithms ChFi2d_FilletAPI dispatches to reconstruct 3D points from myPlane->Pln().Position(), so for two edges lying in a plane that does not pass through the world origin, the returned filletEdge landed in the wrong plane while modifiedEdge1/modifiedEdge2 stayed correct, a disconnected result reported as success. Shape.fillet2dEdges gains a planeOrigin: SIMD3<Double> = .zero parameter (mirroring Shape.anaFillet/Shape.filletAlgo’s existing planeOrigin), threaded through to OCCTChFi2dFilletEdges, which now takes planeOx, planeOy, planeOz alongside the normal.

Shape.fixIntersectingWires(faceIndex:) now actually applies its computed fix; EdgeAnalysis.validateEdge now uses the edge’s real SameParameter state (#1461)

fixIntersectingWires previously computed a fix and discarded it, always returning true with no observable change. validateEdge previously always ran the naive same-parameter-value comparison regardless of the edge’s actual SameParameter flag, understating deviation for edges where it’s false.

plateSurface’s point constraints also reject .g1, not just .g2 (#1460)

.g1 on a point constraint (Shape.plateSurface(through:orders:), and the point half of Shape.plateSurface(pointConstraints:curveConstraints:)) was a silent no-op, not a diagnostic. GeomPlate_PointConstraint’s point-only constructor throws only for order above 1, so .g1 (myOrder == 1) passed straight through, but its member-init list never sets the tangent-derivative fields D1() returns, so they default to (0,0,0). GeomPlate_BuildPlateSurface::LoadPoint’s order-1 branch fed that zero vector into Plate_GtoCConstraint, whose constructor computes a zero cross product, sees normale.Modulus() < NORMIN, and returns before adding any constraint: nb_PPConstraints stayed 0, the tangent request never reached the solver, and the returned surface was silently G0-only, with IsDone() == true and no diagnostic anywhere in the chain.

Verified directly against the pinned V8_0_1 sources (GeomPlate_PointConstraint.cxx, GeomPlate_BuildPlateSurface.cxx’s LoadPoint, Plate_GtoCConstraint.cxx’s zero-normal early return) before fixing this.

Extends #437’s fix for the same underlying reason: a bare point cannot carry tangent data any more than it can carry curvature. SurfaceContinuity.isUnsupportedForPointConstraint now reads self != .g0 instead of self == .g2, rejecting both .g1 and .g2 for a point constraint in Swift, before any GeomPlate_PointConstraint is built. GeomPlate_CurveConstraint has no such restriction (it is built from an Adaptor3d_Curve, which can always supply a tangent), so curve orders are unaffected: both .g1 and .g2 remain fully supported for a curve constraint.

Unlike .g2, this guard is not decorative: OCCT’s own constructor never rejected .g1, so the public answer for a .g1 point request changes from a real, geometrically-degraded surface to nil. Issue1460PlatePointG1Tests has the guard-removal matrix proving the new mechanism is load-bearing, per okf/policies/prove-the-test-fails.md. AdvancedPlateSurfaceTests’s own “mixed G0/G1 orders” test, which only ever asserted shape != nil, exercising the silent-no-op path without measuring whether the tangent constraint did anything, now asserts the rejection instead.

Fixed BSpline knot-sequence undercount and ExtremaPC’s degenerate search domain (#1456)

Curve3D.bsplineKnotSequence() on a periodic B-spline used to silently return one fewer knot value than actually exists (the bridge sized its scratch buffer using the non-periodic flat-knot-sequence formula). Curve3D.extrema(from:) used to always search a degenerate [0, 0] parameter domain instead of the curve’s natural range, so it could only ever find an extremum that happened to sit at parameter 0; every other true nearest/farthest point was silently missed (or, worse, whatever landed at parameter 0 was returned as if it were the answer). Both fixed bridge-side; no kernel change.

Shape.featFuse(with:)/.featCut(with:) were a complete silent no-op, and now actually perform the operation (#1458)

Flagging loudly: this changes the returned geometry for every existing caller of these two methods, from wrong to correct. OCCTBRepFeatBuilderFuse/OCCTBRepFeatBuilderCut (OCCTBridge_Modeling_Features.mm) never called BRepFeat_Builder::Perform(), so the PaveFiller intersection never ran. Shape.featFuse(with:) returned a non-nil, isValid == true, empty compound (volume 0) for every call; Shape.featCut(with:) returned a non-nil, isValid == true, byte-for-byte-unmodified copy of the input for every call. Neither HasErrors() nor isValid could detect it. Fixed by adding the missing Perform() call to both, and dropping Fuse’s PartsOfTool()/KeepPart loop, which (once Perform() actually ran) selected the wrong artifact for a plain fuse rather than being merely redundant. Any caller relying on the previous (broken) behavior (an empty Fuse result, or a Cut that removed nothing) sees a real geometry change.

OCCTGeomLibCheckBSpline3D/2D no longer returns nil for virtually every real curve (#1457)

Curve3D.checkBSplineTangents() and Curve2D.checkBSplineTangents() gated their result on GeomLib_CheckBSplineCurve::IsDone() (and its 2D twin), which OCCT only ever sets true for a periodic curve or one with fewer than 4 poles, never along the real tangent-analysis branch an ordinary curve takes. Both entry points now call NeedTangentFix directly, matching the existing fixBSplineTangents() pattern, so a caller now gets the real (fixFirst, fixLast) result instead of nil.

OCCTExtremaElSSPlanePlane’s parallel branch honors max == 0 (#1463)

The parallel-plane branch of OCCTExtremaElSSPlanePlane (OCCTBridge_Surface_Extrema.mm) returned 1 unconditionally even when max == 0 and nothing was written to the output buffer, unlike every other branch in the file, which gates the write and the returned count together. Fixed: return (max > 0) ? 1 : 0;. Latent (unreachable through the public Swift API, which always passes a 10-element buffer), fixed proactively.

Mesh: tessellation deflection argument order, a Polygon3D SIGSEGV, and point-cloud tolerance default (#1440)

Shape.edgePolyline(at:deflection:) now applies the requested chordal deflection to the correct constructor slot (previously masked only at the default). Polygon3D.parameter(at:) on a no-params polygon now safely returns instead of crashing. Auto-density point clouds now use OCCT’s own default tolerance instead of 0.0.

Datums now land on the real GD&T table, not a private one on Main() (#1435, regression of #1051)

Document.createDatum/.datumCount/.datum(at:) previously wrote and read datums via a table attached directly to the document’s Main() label, invisible to STEP export, RescaleGeometry, and any other consumer of the real XCAFDoc_DocumentTool::DimTolTool table. Fixed to use the real table; a document’s datums are now visible everywhere a real GD&T consumer looks for them.

Fixed 4 defects in OCCTBridge_Healing_Analysis.mm: null-shape SIGSEGV, always-false checkOverlapping, wrong-quantity checkPCurveRange, undercounted gapCount (#1438)

  • Shape.recognizeCanonicalSurface(tolerance:) / recognizeCanonicalCurve(tolerance:) no longer SIGSEGV on a .nullified shape (uncatchable crash, now refused with .type == .none like every other invalid input).
  • EdgeAnalysis.checkOverlapping(_:_:tolerance:) gained a real tolerance parameter (default 1e-7); it used to zero its own working tolerance before use and so always returned overlapping: false, regardless of the input edges.
  • EdgeAnalysis.checkPCurveRange(_:face:first:last:) now checks against the pcurve’s own underlying geometric domain (its full period, for a periodic pcurve) instead of the edge’s current stored trim range, matching ShapeAnalysis_Edge::CheckPCurveRange’s real contract. A range can now be valid even when it extends past where the edge itself is trimmed.
  • Shape.analyze(tolerance:)’s gapCount now counts each individual edge-to-edge gap, not each wire that contains at least one gap; a wire with 2 independent gaps used to report 1.
  • Shape.toleranceValue(mode:subShapeType:) and its toleranceOverCount/ toleranceInRangeCount siblings now guard a null shape wrapper, matching every other bridge function in the file (hardening; not reachable through the public Swift API today).

Fixed OCCTBRepGraphHistoryGetRecordInfo’s NUL terminator and Edge.dihedralAngle’s unreachable concave range (#1434)

OCCTBRepGraphHistoryGetRecordInfo wrote the ASCII digit '0' (0x30) instead of the real NUL terminator (0x00) at its truncation point, so a C-string reader running past the header’s promised boundary would never find a real zero byte.

Edge.dihedralAngle(between:and:) used to return std::acos(normal1 . normal2) unconverted, which answers only in [0, PI]: a convex edge and its complementary concave edge (same two face normals, opposite material side) returned the identical value, so the documented 0...2*PI range (less than PI convex, greater than PI concave) was unreachable. Worse, the raw value wasn’t even the correct interior dihedral angle for the convex case in general, only by coincidence at a box’s self-symmetric 90-degree edges. Now uses OCCT’s own ChFi3d::DefineConnectType classifier (the same one Edge convexity queries already use) to report the true interior angle across the full documented range.

Fixed a heap-buffer-overflow-class sizing bug in BRepGProp_Face knot introspection (#1433)

Shape.faceIntegrationKnotsV() and Shape.faceBoundaryIntegration(edgeIndex:precision:) sized their scratch knot array to BRepGProp_Face’s subinterval count instead of the knot count it actually writes (one more), truncating the last knot on every call and, in the pinned Release kernel (built with No_Exception), writing one double past the allocation with no bounds check. Fixed by sizing the array correctly. Also fixed a new/delete type mismatch in Surface.uReversed()/vReversed() found in the same file.

DiskInfo.size()/.freeSpace() no longer report 2x too many KB, UnicodeUtils.convertToUnicode() no longer drops non-ASCII text, DiskInfo.isValid() now checks the real OCCT status (#1442)

DiskInfo.size(path:)/.freeSpace(path:) returned OSD_Disk’s raw 512-byte block count while documented as KB, exactly 2x the true value. UnicodeUtils.convertToUnicode(_:) silently dropped every non-ASCII character converted from SJIS/EUC/GB/ANSI input instead of UTF-8-encoding it, so non-ASCII text came back empty or truncated; it now UTF-8-encodes correctly. DiskInfo.isValid(path:) tested whether construction threw rather than the real OCCT failure flag, so it returned true for a nonexistent path; it now correctly returns false. The underlying OSD_Disk construction also switched to the overload that actually works on macOS/iOS/Linux (the previous one left every disk query silently non-functional on those platforms via an unrelated, newly-discovered defect in the same construction path).

Five gp_Ax3 bridge functions no longer swallow construction failures with an empty catch (#1443)

OCCTAx3Create, OCCTAx3CreateFromNormal, OCCTAx3MirrorPoint, OCCTAx3Rotate, and OCCTAx3Translate used to catch OCCT’s Standard_ConstructionError (raised for a zero-length direction/xDirection, a parallel direction/xDirection pair, or a zero-length rotation axis) with an empty catch (...), leaving every out-parameter at whatever the caller happened to pre-initialize it to. Each now writes a deterministic fallback instead: isDirect = false plus an all-zero xDirection/yDirection for CoordinateSystem3D.init(origin:direction:xDirection:) and init(origin:direction:) (unambiguous, since a real result direction is always a unit vector), and the input point unmoved (plus an all-zero direction/xDirection where applicable) for mirrored(about:), rotated(about:axisDirection:angle:), and translated(by:).

OCCTShapeSelfIntersectsDetailed no longer reports .intersects for a refused argument (#1436)

isSelfIntersectingDetailed(timeout:) used BOPAlgo_ArgumentAnalyzer::HasFaulty(), which is true for any recorded fault, not just a genuine self-intersection. A shape with no geometry (e.g. .emptied) made the analyzer record BOPAlgo_BadType and return before any self-intersection test ran, and the old code reported that as .intersects regardless. It now reads the check results by status, mirroring the fix isSelfIntersecting(timeout:) already carried from #1054, and reports .error for a refused or otherwise unanalysable argument instead. Also fixed: five internal Shape.History bridge functions (merge, replaceGenerated, replaceModified, modifiedShapes(of:), generatedShapes(of:)) cast the opaque history handle to the wrong pointer type; harmless on the current ABI, but a real strict-aliasing violation, now matching every other consumer of the same handle.

Fixed OCCTFilletSurfError’s doc comment and exception fallback, off by one against FilletSurf_ErrorTypeStatus (#1439)

OCCTFilletSurfError’s doc comment (@return 0=EdgeNotG1, ... 4=PbFilletCompute) was one off against FilletSurf_ErrorTypeStatus’s real enum for every value it named, and its catch (...) fallback returned the literal 4, which collides with the real, legitimately-returned FilletSurf_NotSharpEdge (also 4). Corrected the doc comment to 0=EmptyList, 1=EdgeNotG1, 2=FacesNotG1, 3=EdgeNotOnShape, 4=NotSharpEdge, 5=PbFilletCompute, and changed the fallback to 5 (PbFilletCompute) so a caught exception is distinguishable from a genuine NotSharpEdge verdict.

Fix Bezier→BSpline buffer overflow and silently-dropped curves in composite joins (#1441)

OCCTConvertCompBezierToBSpline/OCCTConvertCompBezier2dToBSpline2d now reject (return false) a composite curve whose true pole/knot count exceeds OCCTBezierBSplineResult’s/ OCCTBezierBSpline2dResult’s fixed capacity (100 poles, 50 knots), instead of reporting the unclamped true count against a buffer that only ever held a truncated prefix; the Swift consumer (CompBezierConverter) trusted that count as its loop bound over the fixed-size buffer, a real out-of-bounds read for a large enough composite curve.

OCCTCurve3DJoinToBSpline/OCCTCurve3DConcatenateG1 now check GeomConvert_CompCurveToBSplineCurve::Add()’s return value and return nullptr on failure, instead of discarding it. Add() is a documented no-op when the new curve isn’t G0-continuous with the accumulated one, so a curve that failed to join used to be silently dropped while the function still returned a non-nil, partial result. Curve3D.join/Curve3D.concatenateG1 now correctly return nil in that case, matching the already-correct Curve3D/OCCTCurve3DJoinCurves/ OCCTConcatenateCurves3D siblings.

Visualization fallback defaults, unscaled relative deflection, and wrong PBR roughness accessor (#1417, #1418, #1419)

Three independent Wave 1 bugs, fixed together because they share files:

  • OCCTTextLabelGetInfo now reads the label’s real text height via Prs3d_TextAspect::Height() instead of always reporting a hardcoded, and wrong, 12.0 (OCCT’s own default is 16.0).
  • OCCTCameraGetScale’s null-handle fallback is now 1000.0, matching Graphic3d_Camera’s real default (was 1.0, off by a factor of 1000).
  • Shape.shadedMesh(drawer:)/edgeMesh(drawer:) now correctly scale a relative deflection type’s DeviationCoefficient() by the shape’s own bounding-box diagonal (matching OCCT’s own StdPrs_ToolTriangulatedShape::GetDeflection), instead of treating the bare dimensionless coefficient as an absolute deflection. This affected every default-settings call, producing drastically over- or under-tessellated meshes depending on the shape’s real-world scale.
  • Material.predefinedMaterial(named:)/predefinedMaterial(at:)’s pbrRoughness now reports the material’s authored roughness (Graphic3d_PBRMaterial::NormalizedRoughness()) instead of an internal calculation-space remap (Roughness()), matching glTF’s roughnessFactor semantics the type’s own docs promise.

Fixed a crash on a null shape passed to a face bounding-box query (#1424)

OCCTBndLibFace (backing BndLib.face(_:tolerance:)) now guards a genuinely-null OCCTShapeRef the same way its sibling OCCTBndLibEdge already did, returning a zeroed bounding box instead of crashing.

Shape.pipeShellMultiSection(..., solid: true)/pipeShellWithLaw(..., solid: true) now refuse instead of silently returning an open shell (#1414)

A solid: true request whose profile can’t be capped into a solid (e.g. an unclosed end profile) used to silently return the open shell as if it were the requested solid, with no way to tell it apart from a genuine result. Now returns nil.

Wire.analyze().isClosed/.hasSelfIntersection now report real geometry instead of always true/false (#1415)

OCCTWireAnalyze’s underlying ShapeAnalysis_Wire never had its face set, so both fields were structurally incapable of reporting anything but their always-true/always-false defaults. isClosed is now a direct 3D endpoint check; hasSelfIntersection uses a fitted planar face (for a non-planar wire, still not checkable – a real, acknowledged limitation, not new).

MessageSystem.loadDefault() now loads a real OCCT message set (#1422)

OCCTMessageMsgFileLoadDefault referenced a fabricated environment variable (CSF_XHatch, not a real OCCT resource-file variable anywhere in the kernel) with an empty file name, so the call could structurally never succeed. It now calls ShapeExtend::Init(), the real upstream loader for OCCT’s Shape Healing (ShapeFix) diagnostic message set (CSF_SHMessage/"SHAPE"), chosen over the other real precedent (Interface_Static::Standards(), XSTEP) because it does nothing but load messages, matching this function’s own narrow, documented contract; Interface_Static::Standards() also configures ~15 unrelated XSTEP read/write precision/tolerance parameters as a side effect. ShapeExtend::Init() falls back to a message set compiled directly into the OCCT static library when no CSF_SHMessage resource file is found (true for this project and for any downstream SwiftPM consumer), so MessageSystem.loadDefault() now reliably returns true and MessageSystem.hasMessage(forKey: "ShapeFix.FixSmallSolid.MSG0") becomes true afterward:

MessageSystem.loadDefault()
let hasSmallSolidMessage = MessageSystem.hasMessage(forKey: "ShapeFix.FixSmallSolid.MSG0")
// hasSmallSolidMessage == true

Fixed OCCTCurve2DHatch silently truncating hatch output at half the caller’s buffer capacity (#1420)

Curve2DGcc.hatch(boundaries:...) sizes its output buffer for maxSegments segments (4 doubles each) and passes maxSegments through to OCCTCurve2DHatch’s last parameter, but that parameter’s internal guard read it as a point count (2 doubles each), capping real output at half the buffer’s capacity – silently, with no truncation signal. Any hatch producing more than maxSegments / 2 segments (but no more than maxSegments) came back truncated. Fixed by renaming the parameter maxPoints -> maxSegments and correcting the guard to match, aligning it with the sibling OCCTHatchLines/HatchPattern.generate’s established maxSegments/outSegments convention.

// A boundary/spacing combination producing more than 2048 segments now returns the true count
// instead of being silently capped at 2048:
let segments = Curve2DGcc.hatch(
    boundaries: tallRectangleBoundary,
    spacing: 1.0)
print(segments.count) // now the true count (e.g. ~3000), not capped at 2048

OCCTContapCylinderDir/Shape.contourCylinderDir now returns both tangent lines (#1416)

Fixed a bug where Shape.contourCylinderDir silently discarded the second of the two tangent lines a cylinder’s silhouette always produces against a non-degenerate view direction (Contap_ContAna::Perform(gp_Cylinder, gp_Dir) sets nbSol to exactly 0 or 2, never 1). The underlying bridge function OCCTContapCylinderDir only ever wrote the first line into its outData buffer while still reporting count == 2, making the second contour’s geometry unrecoverable.

Shape.contourCylinderDir’s ContourResult.data now holds 12 doubles instead of 8 when the contour type is a line: line 1’s location xyz + direction xyz at data[0...5], line 2’s at data[6...11]. Shape.contourSphereDir/Shape.contourSphereEye are unaffected (unchanged 8-double, single-line/circle buffer); both underlying Contap_ContAna sphere overloads only ever produce one contour.

if let result = Shape.contourCylinderDir(
    origin: SIMD3(0, 0, 0), axis: SIMD3(0, 0, 1),
    radius: 5, direction: SIMD3(1, 0, 0)),
   result.count == 2 {
    let line1Location = SIMD3(result.data[0], result.data[1], result.data[2])
    let line2Location = SIMD3(result.data[6], result.data[7], result.data[8])
}

Drawing.edges(ofType:)/.hiddenEdges/.outlineEdges now correctly return nil for a genuinely empty edge category (#1421)

Previously returned a valid but empty Shape for a category with zero contributing edges (e.g. .hidden on a convex shape, .outline on a shape with no curved surfaces), contradicting the documented nil contract. Fixed: MakeCompound always returns a non-null handle regardless of content, so the old compound.IsNull() guard could never fire; now tracks whether anything was actually added.

Documented BRepGraph’s undocumented no-op setters (#1001)

BRepGraph’s eighteen editor-view members that are silent no-ops against the pinned OCCT 8.0.0p1 kernel (edgeMaxContinuity, the edge/wire/face/shell derived-flag setters, two coedge/wire ref setters, setCoEdgeUVBox, the six Set*RefLocalLocation setters, and repSetPolygonOnTriTriangulationId) now say so in their doc comments and point at the real derived-value getter where one exists. No signature or behavior changed; these have always been no-ops, only the documentation was missing.

Gate coverage-of-the-gates audit (#819 Phase 6 item 3)

  • New committed artifact Scripts/repro/819-gate-coverage-audit/: live-derives the current gate/census/audit set (8/4/1, structurally classified, --check mode keeps this from going stale) and cross-references 23 defect classes the refactor programme has found against it. 9 gated, 4 census-covered, 1 audit-covered, 1 process-only, 4 not gateable by any static script (kernel data races/crashes/wrong-answers live in vendored C++ source), 4 genuine ungated gaps.
  • Filed #1407 (missing try/catch gate), #1408 (stale self-referential count gate: #1066 is a live instance), #1409 (stale tsan.supp suppression gate). None built here, per this project’s own “materially new workstream gets filed and deferred” rule.

@unchecked Sendable audit: EdgeCurve/WireCurve no longer Sendable (#1162)

  • BREAKING: EdgeCurve and WireCurve are no longer Sendable. Their bridge structs hold a persistent BSpline-adaptor evaluation cache mutated by every accessor with zero synchronization (#1153) – every method looked like a pure query, which made the conformance actively misleading. Construct one instance per thread/task rather than sharing one, or serialize access with OCCTSerial.withLock { } (the pattern was already correct; the compiler now enforces it).
  • Audited and re-verified all 27+ classes #1162 named (29 total, including WireCurve found along the way) against what’s actually shipped, not the issue’s own citations (all six of which were stale, wrong, or answered a different question). 21 classes’ doc comments corrected/strengthened to name the real hazard; 5 reclassified genuinely safe with evidence. Full audit: Scripts/repro/1162-sendable-audit/.
  • Filed #1404: TObjApplication wraps a separate, previously-uncharacterized unsynchronized singleton, unrelated to the stale #344 citation.
  • docs/thread-safety.md: new subsection documenting the audit and this project’s explicit @unchecked Sendable convention (handle-move-safe, not concurrent-mutation-safe).

#369 root-caused: OSD_ThreadPool cross-caller “corruption” was never a bug

  • Root-caused #369: the 27-vs-13-face divergence #367 found was BRepAlgoAPI_BuilderAlgo (General Fuse) legitimately differing from a BRepAlgoAPI_Fuse baseline the reproducer compared it against: a test-harness bug, not a pool defect. OSD_ThreadPool::DefaultPool() confirmed safe for concurrent independent submitters via direct isolation testing (zero races across two dedicated reproducers) and a corrected re-run of the original scenario (0 races, down from 237, against a kernel carrying #1153/#1154’s since-landed fixes). Fixed the test harness’s own baseline comparison; corrected the now-inaccurate bridge comment (OCCTBridge_Modeling_Boolean.mm, comment-only, functional behavior unchanged).

Interface_Static thread safety investigation (#1157)

  • New carried kernel patch Scripts/patches/0033-Interface_Static-thread-safety-mutex-1157.patch (override-link validated, not yet in a rebuilt xcframework): a std::recursive_mutex guards all 17 of Interface_Static’s static entry points, closing a confirmed memory-safety race (concurrent NCollection_DataMap mutation and shared-buffer corruption) reachable by every STEP/IGES consumer. Deliberately partial: does not make concurrent operations setting different values for the same named parameter produce correct output – igesMutex() remains the bridge’s own defense for that, unchanged.
  • Corrected a stale patch-count paragraph in both CLAUDE.md and Package.swift (said twenty-one/four since before #1371 landed; now twenty-three/six).

Phase 6: whole-surface refman coverage reconciliation (#820)

  • New committed artifact Scripts/repro/820-refman-coverage-whole-surface/: unions the nine #807 source lanes (1,730 classes, zero double-claims: the lanes partition cleanly) plus a new substrate audit of #1045’s fifteen packages (337 classes, 257 newly recorded in docs/occtswift-wrapping-gaps.md). Diffed against the full 6,774-header pinned surface: 642 wrapped-but-unowned classes filed as #1399, 3,983 unwrapped classes bucketed by module, 82 low-confidence residual left unadjudicated.
  • docs/occtswift-wrapping-gaps.md’s headline count corrected from a 4-month-stale 4,256/1,166 to the current, gate-agreeing 4,365/1,174.

Refman coverage audit: tests, Document/XCAF (#817, Pass 5c of #807/#819)

  • New committed census Scripts/repro/817-refman-coverage-tests-document/: 119 ok of 131 wrapped+documented classes, 12 under (real coverage exists outside the lane, filed as #1396), 0 confirmed over-coverage. One genuine gap fixed in this PR: Document.lengthUnit (XCAFDoc_LengthUnit) had zero test coverage, now covered by two new tests in Tests/OCCTXCAFTests/DocumentTests.swift.

Refman coverage audit: tests, geometry primitives (#815, Pass 5a of #807/#819)

  • New committed census Scripts/repro/815-refman-coverage-tests-geometry/: 550 ok of 1288 derived members; 13 genuine under-coverage findings fixed in this PR (new tests for Curve3D.d2/.bsplineSetKnot, Curve2D.d2/.allExtrema/.selfIntersections, OCCTPrecision.infinite/.pConfusion, Surface.bsplineSetUKnot/.bsplineSetVKnot/ .bsplineRemoveUKnot/.bsplineIncreaseVMultiplicity/.isUClosed/.isVClosed); 1 over-coverage finding fixed (a stale sphere isVClosed doc comment).

Refman coverage audit: tests, peripheral subsystems (#818, Pass 5d of #807/#819)

  • New committed census Scripts/repro/818-refman-coverage-tests-peripheral/: of 136 wrapped classes across #811-#814’s four source lanes, 134 tested (53 in-lane, 81 correctly in a sibling domain target), 1 fixed here (BRepOffsetAPI_MiddlePath: new Tests/OCCTModelingTests/Issue818MiddlePathTests.swift), 1 filed as #1393 (LocOpe_SplitDrafts). Zero confirmed over-coverage.

Refman coverage audit: tests, Shape/Topology (#816, Pass 5b of #807/#819)

  • New committed census Scripts/repro/816-refman-coverage-tests-topology/: 65 ok, 0 deliberate, recorded, 1 under (filed as #1392: BRepCheck_Solid’s bridge function has no Swift caller), 0 confirmed over-coverage across 37 hand-adjudicated candidates.

New census: comment-staleness (#872)

  • Scripts/census-comment-staleness.py: four channels (Sources/OCCTSwift dotted symbol mentions, Sources/OCCTBridge bridge-function mentions, Scripts/*.py usage-flag drift, CLAUDE.md patch-number citations), wired into ci.yml’s gate-scripts job and the optional pre-commit hook (--self-test only, same as the other three censuses)
  • Four stale comment references fixed, its first real catch: ConstructionLayer.materialize -> ConstructionContext.materialize (3 sites), ShapeMeasurements.measure() -> Shape.measure(), Curve3D.localCurvature -> Curve3D.curvature(at:), ConstructionEntity.resolveFaceAxisDirection -> BRepGraph.resolveFaceAxisDirection

Partition census for #1045’s fifteen unowned substrate packages

  • Scripts/repro/1045-substrate-package-partition/partition_census.py: assigns all fifteen (GeomFill_, BRepFill_, BRepOffset_, BRepBlend_, Blend_, BlendFunc_, ChFiDS_, ChFiKPart_, Draft_, BiTgte_, MAT_, MAT2d_, Bisector_, AdvApp2Var_, AdvApprox_) to #820 (Phase 6), GeomFill_/BRepFill_ flagged HIGH priority

Fix: TopOpeBRepBuild KPart-merge file-scope statics are thread_local (#1371)

  • Twelve unsynchronized file-scope statics in the legacy fillet/chamfer reconstruction engine (TopOpeBRepBuild_ffsfs.cxx/GridSS.cxx/GridFF.cxx) converted to thread_local, matching #298’s precedent in the same toolkit
  • Confirmed currently unreachable from BRepFilletAPI_MakeFillet/MakeChamfer; fixed ahead of reachability, not in response to an observed failure
  • Kernel-only (Scripts/patches/0032), no public API change, not yet in a rebuilt xcframework
  • Retired before it ever shipped (#1472). Upstream fixed the same globals better, and more widely, in OCCT#1505 and OCCT#1509, four days after this patch landed. 0032 is deleted rather than carried, so nothing in this release note describes code that exists. No xcframework was ever built with it, so no consumer saw either state. Scripts/patches/README.md and okf/references/carried-occt-patches.md record the retirement, and CLAUDE.md gains the “check upstream’s own recent activity first” step this cost bought

Share DXF group-code formatter via DrawingTestFixtures.DXFTestFormat (#1271)

  • New DXFTestFormat enum in DrawingTestFixtures.swift mirrors DXFExporter.swift’s internal formatting
  • Issue1173ArrowheadTriangleGeometryTests now uses DXFTestFormat.lineEntity instead of private duplicate
  • OCCTDrawingTests.swift formatter not yet migrated (separate follow-up)

Unify SIMD3.normalized inline implementations with shared epsilon threshold (#1275)

  • New ModelingTestExtensions.swift provides SIMD3.normalized with 1e-10 threshold
  • ShapeSplittingTests and IntegrationThicknessAnalysisTests now use the shared helper
  • Eliminates divergent zero-guard logic and ensures consistent behavior

Deduplicate overlappingBoxes/stackedBoxes boolean fixtures into BooleanTestFixtures (#1273)

  • New BooleanTestFixtures enum in Tests/OCCTModelingTests/BooleanTestFixtures.swift
  • Issue206BooleanTimeoutTests, Issue1067BooleanOutcomeTests, Issue832BooleanDelegationTests, Issue202BooleanOptionsTests all reference the shared fixtures
  • overlappingBoxes(): two 10mm boxes at (0,0,0) and (5,0,0)
  • stackedBoxes(): two 10mm boxes at (0,0,0) and (0,0,10)

Consolidate BOPAlgoRemoveFeaturesTests into Issue497DefeaturingTests (#1274)

  • 3 tests from BOPAlgoRemoveFeaturesTests merged into Issue497DefeaturingTests:
    • removeFilletFromBox → removeFilletFromBoxViaDefeature
    • removeFeaturesEmptyFaces → defeatureEmptyFaces
    • removeFaceFromBox → removeFaceFromBoxViaDefeature
  • BOPAlgoRemoveFeaturesTests.swift deleted
  • Test terminology updated from deprecated removeFeatures to current defeature

Replace tautological assertions in ChamferBuilder/FilletBuilder tests (#1277)

  • ChamferBuilderCompletionsV124Tests: #expect(!closed || closed) → #expect(!closed); #expect(!cat || cat) → #expect(!cat)
  • FilletBuilderCompletionsV124Tests: same fixes
  • Single-edge chamfer/fillet contours are correctly reported as not closed and not closed-and-tangent

Deduplicate openShell/declinedIndices fillet fixtures into FilletTestFixtures (#1272)

  • New FilletTestFixtures enum in Tests/OCCTModelingTests/FilletTestFixtures.swift
  • Issue612FilletContourSelectionTests, Issue633BlendedEdgesDuplicateReportTests, Issue639FilletDeclinedEdgeReportTests all reference the shared fixture
  • acceptedIndices kept local to Issue639 (only used there)

Deduplicate Gluer face-pair search harness into ModelingTestExtensions (#1276)

  • New tryGlueAllFacePairs helper in ModelingTestExtensions.swift tries all face pairs with a given glue function
  • BRepFeatGluerTests and LocOpeGluerTests both use the shared helper
  • ShapeSplittingTests now uses shared SIMD3.normalized from same file

Fix boxTopEdges helper to return actual top-face edges (#1278)

  • boxTopEdges() now filters box edges to only those at Z=5 (top face of centered box)
  • Comment updated to reflect Z+ face of centered box
  • Test fillWithBoxEdges now correctly receives 4 edges

Migrate writeBREPAllowInvalid to shared invalidBowtieShape helper (#1279)

  • BREPTests.writeBREPAllowInvalid now uses IOTestFixtures.invalidBowtieShape()
  • Removes duplicate bowtie polygon face construction
  • Consistent with how other IO tests reference the shared fixture

Merge IGESExportGuardTests into IGESTests (#1283)

  • Delete Tests/OCCTIOTests/IGESExportGuardTests.swift (single redundant test)
  • IGESTests already covers valid IGES export in exportIGES()
  • No test coverage lost

Thread-safety survey: eight candidate classes confirmed clean (Issue #1155)

Surveyed all eight classes #1155 named as “algorithms with internal mutable state” (BRepBuilderAPI_Transform, BRepClass3d_SolidClassifier, GeomAPI_ProjectPointOnSurf, BRepBuilderAPI_MakeEdge/MakeWire/MakeFace, BRepOffsetAPI_MakePipeShell/MakeThickSolid, BRepFilletAPI_MakeFillet/MakeChamfer, ShapeFix_Face/Wire/Shape, BRepCheck_Analyzer). All eight confirmed clean: none hold file-scope/static mutable state reachable from ordinary use. New TSan stress harness added (Scripts/repro/1155-thread-safety-survey/occt_1155_stress.cpp), registered in Scripts/tsan-stress.sh’s gate matrix, 8 threads x 30 iterations per scenario, 0 races. No kernel patch, no bridge change, no public API change.

Shape.isValidForBoolean/isValidForBoolean(with:) converge onto isBooleanValid/isBooleanValidWith, gain a null guard (#1297)

isValidForBoolean/isValidForBoolean(with:) and isBooleanValid/isBooleanValidWith reached the same BRepAlgoAPI_Check operation through two independent bridge implementations (OCCTShapeBooleanCheck vs. OCCTShapeBooleanCheckSingle/OCCTShapeBooleanCheckPair); the newer pair never carried the older one’s null guard on its OCCTShapeRef parameters. Converged onto the fuller implementation (which already exposes testSmallEdges/testSelfInterference/operation), added the missing guard, and deleted the duplicate. isValidForBoolean/isValidForBoolean(with:) now forward onto isBooleanValid()/isBooleanValidWith(_:) at their existing implicit defaults (both confirmed identical to BRepAlgoAPI_Check’s own C++ constructor defaults), so behavior is unchanged. No public Swift API changes.

Test-only: mesh crest-radius measurement deduplicated in OCCTThreadTests, -1 sentinel bug fixed (#1266)

The maximum XY-planar radial distance across a shape’s meshed vertices (the “crest radius” of a threaded solid) was reimplemented four times across Tests/OCCTThreadTests/ (ThreadFormsTests.externalForm/.roundedExternalForm, Issue257MultiStartTests.meshCrestRadius, Issue222Envelope.crestRadiusMesh), with a real bug in one copy: meshCrestRadius returned a -1 sentinel on Shape.mesh failure, and every one of its three call sites compared the result with <= against a positive nominal radius (-1 <= 5.0 * 1.005 is trivially true), so a genuine measurement failure would have silently reported success instead of being caught. All four are now one shared meshMaxRadialExtent(_:deflection:) helper (OCCTThreadTests.swift), returning nil on failure; the three previously-buggy call sites now explicitly record a failure instead of silently passing. No production behavior change: same geometry, same measurement, only the duplication and the sentinel bug fixed.

ShapeHealing test fixture dedup, totalProblems reimplementation drift, and stale doc refs (#1287, #1288, #1290)

Part of the tests duplication sweep (#390, itself a sub-issue of #377). Test-only: no production API or behavior change.

  • #1287: expectVolume/twoBoxes/hollowBox/multiconnexSolid were duplicated byte-for-byte between Issue442FixSolidMultiBodyTests.swift and Issue443FirstOfNTests.swift, bypassing ShapeHealingTestFixtures.swift. All four now live there; Issue443FirstOfN.solidFromMulticonnex calls the shared multiconnexSolid() instead of inlining the identical construction.
  • #1288: Issue702SolidDemotionTests.totalProblemsExcludingFreeFace reimplemented ShapeAnalysisResult.totalProblems and had drifted, missing the hasSelfIntersection term. Fixed to include it, matching ShapeAnalysisTests.analysisResultProperties’s own mirror of the same contract. A new test (totalProblemsExcludingFreeFaceIncludesSelfIntersection) exercises a genuinely self-intersecting fixture, the first in this file to do so, which is what actually makes the previously-omitted term matter (every other call site’s hasSelfIntersection is always nil).
  • #1290: doc comments in ShapeHealingTestFixtures.swift, Issue442FixSolidMultiBodyTests.swift and Issue702SolidDemotionTests.swift named Issue442FixSolidMultiBodyTests/ Issue702SolidDemotionTests, but neither struct carries a Tests suffix (Issue442FixSolidMultiBody, Issue702SolidDemotion). Corrected all four references.

Test-only: deduplicated robust-import cancellation fixtures and the ImportProgress recorder in OCCTIOTests (#1281, #1282)

No source behavior change. The “row of N translated 10x10x10 boxes” and “1200-sided N-gon prism” fixtures, each independently reimplemented across CancellationReportingTests, MultibodyRobustImportTests, and RobustImportProgressTests, now share two factory functions in IOTestFixtures.swift: boxRow(count:) and ngonPrism(sides:radius:height:). Separately, MeshAndExportProgressTests.Recorder and ImportProgressTests.ProgressRecorder, the same lock-guarded ImportProgress recorder under two names, are now one shared ProgressRecorder in IOTestFixtures.swift.

Mesh triangulation test duplication removed (#1268, #1269)

Tests/OCCTMeshTests/: CoherentTriangulationTests’s copy-pasted 4-node/2-triangle fixture (three identical builds across addTriangles, removeTriangle, computeLinks) is now one shared twoTriangleMesh() helper. Issue613MeshIndexContractTests.swift now imports simd and computes its triangle-winding normal/dot product via simd_cross/simd_dot instead of hand-rolled arithmetic, matching Issue375MeshWindingTests.swift’s existing convention. Test-only, no public API impact.

Fixed data races in BSpline adaptor evaluation caches (Issue #1153)

Multiple data races existed when multiple threads concurrently evaluated the same GeomAdaptor_Curve or GeomAdaptor_Surface wrapping BSpline curves/surfaces. The mutable BSplCLib_Cache/BSplSLib_Cache inside the adaptors were accessed without synchronization across Value, D0, D1, D2, D3 evaluation methods.

Added std::mutex protection at three levels:

  • BSplCLib_Cache: all const evaluation methods lock internal mutex
  • BSplSLib_Cache: all const evaluation methods lock internal mutex
  • GeomAdaptor_Curve/GeomAdaptor_Surface: BSpline/Bezier EvalD0-EvalD3 lock mutex

Verified with ThreadSanitizer: 0 races under maximum stress (8 threads × 1000 iterations = 32,000 evaluations per test type), down from 5 confirmed races before.

Fixed data races in TopoDS_TShape flag mutations (Issue #1154)

TopoDS_TShape::myState was a plain uint16_t modified via non-atomic bitwise operations (|=, &=, ^=). Concurrent flag mutations on shared TShapes (common after boolean operations where result shares TShapes with inputs) caused data races.

Changed myState to std::atomic<uint16_t> with compare_exchange_weak loop in setBit() for lock-free atomic bit operations. All flag getters now use load(std::memory_order_acquire) and setBit uses atomic RMW with memory_order_release/acquire semantics.

Verified with ThreadSanitizer reproducer at Scripts/repro/1154-topology-flag-race/occt_1154_stress.cpp, and with a new GTest (TopoDS_TShape_Test.ConcurrentFlagMutationsAreNotLost).

Factor five-of-six-faces open-shell fixture into shared helper (#1296)

Factor void-shape and zero-size-vertex test fixtures into shared helpers (#1295)

Fix doc comment citing non-existent test suite (#1294)

Remove duplicate curvature test, tighten circleRadius tolerance (#1293)

Merge duplicate Surface-Surface Intersection test suites into one (#1292)

Merge duplicate Curve-Surface Intersection test suites into one (#1291)

Test-only: consolidated the four independent makeBSplineSurface() fixtures (#1254)

BSplineSurfaceManipulationTests, BSplineSurfaceExtrasTests, BSplineSurfaceRemoveVKnotTests and BSplineSurfaceCompletionsV121Tests each had their own private makeBSplineSurface() reusing the same name for four different fixtures. Moved into SurfaceTestFixtures.swift under distinct names; no production code or test behavior changed.

classifyPoint2DInside now actually tests .inside (#1284)

Internal only, no public API change. BRepClassFClassifierTests.classifyPoint2DInside asserted .outside against a point far outside the face’s UV bounds, so Shape.classifyPoint2D returning .inside was never exercised anywhere in the suite. It now classifies the midpoint of the face’s own UV bounds, a genuinely interior point.

hollowSolid() test fixture consolidated onto one shared helper (#1265)

Internal only, no public API change. The 20-cube-minus-8-cube-cavity fixture used across Issue211OuterShellTests.swift, Issue439OuterShellMultiSolidTests.swift, and Issue502SubShapeTraversalTests.swift was rebuilt independently five times; all five sites now call one shared Issue211OuterShell.hollowSolid().

lBracket() test fixture naming collision resolved (#1280)

Internal only, no public API change. Issue613IndexContractTests.swift’s lBracket() (a fused-box construction) is renamed fusedLBracket() to stop colliding with the differently-shaped, unrelated lBracket() in GeometricEdgeSelectionTests.swift (an extruded polygon).

WireAnalyzerV124Tests factored its 9x-inlined fixture into one helper (#1285)

Internal only, no public API change. WireAnalyzerV124Tests.swift’s nine tests each independently rebuilt the same rectangle-wire-to-WireAnalyzer construction inside a triple-nested if let; now factored into one rectangleAnalyzer(precision:) helper, called via guard let.

Extracted locateLocalSpan helper in Curve2DBSplineLocalTests (#1258)

All 5 tests in Curve2DBSplineLocalTests repeated an identical 8-line preamble (build a BSpline via interpolation, locate its local knot span, guard both steps) before diverging only in which local-evaluation accessor each exercises. Extracted to a private locateLocalSpan helper. Test-only, no behavior change.

Hoisted expectSameCurve into a shared fixture across the Curve2D interpolation parity suites (#1256)

Curve2DInterpolatePeriodicParityTests and Curve2DInterpolateTangentsParityTests each reimplemented an identical expectSameCurve assertion helper instead of sharing one. Moved to Curve2DInterpolateParityTestFixtures.swift, matching this target’s existing shared-fixture convention (SurfaceTestFixtures.swift). Test-only, no behavior change.

Removed IntToolsFClass2dTests’ pointInside/pointOutside, strictly subsumed by Issue840ClassifyPoint2dToleranceTests (#1257)

IntToolsFClass2dTests.pointInside/.pointOutside duplicated the identical fixture and the identical two assertions Issue840ClassifyPoint2dToleranceTests.wellInsideUnaffected/ .wellOutsideUnaffected already cover, which additionally cross-check Face.classify. IntToolsFClass2dTests.isHoleCheck, unrelated coverage, stays. Test-only, no behavior change.

Folded DocumentMainLabelTests into TDFLabelPropertyTests.labelTag (#1247)

DocumentMainLabelTests.getMainLabel duplicated three assertions already covered by TDFLabelPropertyTests (labelTag, labelDepth, labelIsRoot). Its one real value, an explicit main != nil assertion (the TDFLabelPropertyTests suite’s tests use bare if let with no else, so they’d vacuously pass if mainLabel were ever nil), is now folded into labelTag. The redundant suite is deleted. Test-only, no behavior change.

splitBoxCompound() test fixture naming collision resolved (#1255)

Internal only, no public API change. Tests/OCCTTopologyTests/Issue541FaceIndexContractTests.swift and Issue614FaceOrientationTests.swift carried byte-identical splitBoxCompound() fixtures; #541 now calls #614’s copy instead of keeping its own. Issue979SubShapeIndexIdentityTests.swift’s splitBoxCompound() named a structurally different fixture (a plane split, not a face split) under the same name; renamed to planeSplitBoxCompound() to remove the collision.

Deduplicated the C-string decode closure across two XCAF test suites (#1246)

Issue1078LayerNameLengthTests and Issue1055DatumNameLengthTests each reimplemented the NUL-terminated buffer decode Document.string(fromCString:) already provides, instead of calling it. No behavior change (all three copies were byte-identical to the helper), test-only.

Test-only: shared BSpline continuity fixture across Issue485SurfaceContinuityTests/Issue619SurfaceContinuityEncodingTests (#1253)

bsplineSurface(interiorMultiplicityU:), previously duplicated verbatim in both suites, now lives once in SurfaceTestFixtures.swift as makeContinuityBSplineSurface(interiorMultiplicityU:). No production code changed; no test behavior changed.

MathSolverFunctionRootTests.findRoot(near:) test pair parameterized with @Test(arguments:) (#1250)

MathSolverFunctionRootTests.findRootNewton()/findRootNegative() are now one @Test(arguments:)-parameterized test. Test-only; no production behavior change.

ElCLibTests.valueOnCircle test pair parameterized with @Test(arguments:) (#1249)

ElCLibTests.valueOnCircle()/valueOnCircleAtPiOver2() are now one @Test(arguments:)-parameterized test. Test-only; no production behavior change.

OSDPathTests removed, fully subsumed by PathParsingContractTests (#1286)

OSDPathTests predated #499’s path-parsing unification (PathParsingContractTests) and was never removed once its replacement landed. Confirmed case-by-case that every assertion it made is already covered, with equal or greater strength, by PathParsingContractTests. Test-only; no production code or public API changes.

Issue640MathDimensionBoundsTests Gauss/Crout determinant tolerance tightened to match its source fixtures (#1248)

Issue640MathDimensionBoundsTests’s Gauss/Crout determinant control assertions now use the same 1e-10 tolerance as the MathGaussTests/MathCroutTests fixtures they were copied from, instead of a 10x looser 1e-9. Test-only; no production behavior change.

Test-only dedup: FreeBoundsPropertiesTests’ three earlier tests now call its own twoFaces() helper (#1289)

Internal dedup only, no observable behavior change: freeBoundsOnFaces, closedBoundInfo, and freeBoundWire inlined the identical two-stacked-10x10-faces fixture that twoFaces() (added later in the same struct, #504) already factors out. All three now call the helper.

Tests/OCCTModelingTests/OCCTModelingTests.swift split by @Suite (#1308)

OCCTModelingTests.swift carried 129 @Suite structs across 6853 lines. Split into one file per struct, named after the struct, matching the sibling Issue*Tests.swift files already in the directory. The file-scope SIMD3.normalized helper (used by exactly one suite, ShapeSplittingTests) moved with it as a fileprivate extension; no shared fixtures file was needed, since every other helper in the file was already struct-scoped. No test behavior changed: same assertions, same tolerances, same @Test count (682 across the directory, before and after).

OCCTSurfaceTests.swift split by @Suite into 130 files + shared fixtures (#1300)

OCCTSurfaceTests.swift carried 7,049 lines across 130 @Suite structs. Split one file per suite, named after the struct, matching the target’s existing Issue*Tests.swift convention. The SIMD3.normalized extension and the #645 quarter-cylinder Gordon fixture (shared by GeomFillGordonTests and GeomFillGordonReportTests) move to a new SurfaceTestFixtures.swift, matching the Tests/OCCTShapeHealingTests/ShapeHealingTestFixtures.swift precedent. No public API changes: same types, same signatures, same module, same test count (560 @Tests), just relocated.

OCCTXCAFTests.swift split by @Suite into one file per suite (#1307)

Tests/OCCTXCAFTests/OCCTXCAFTests.swift carried 5,641 lines across 107 @Suite structs (423 @Tests). Split into one file per suite, named after the struct, matching the sibling Issue*Tests.swift files already in that directory. The file’s one file-scope helper (SIMD3.normalized, unused by any suite here) moves to a new XCAFTestFixtures.swift, matching the Tests/OCCTShapeHealingTests/ShapeHealingTestFixtures.swift precedent. No public API changes: same types, same signatures, same module, just relocated. @Test count is unchanged (423 before and after).

OCCTCurveTests.swift split by @Suite into one file per suite (#1305)

Tests/OCCTCurveTests/OCCTCurveTests.swift carried 92 @Suite structs across 5195 lines. Split into 92 files, one per suite, named after the struct, matching the sibling Issue*Tests.swift files already in the directory. The file-scope SIMD3.normalized extension (the target-wide helper CLAUDE.md’s Test Layout section documents, unused within this file) moves to a new Tests/OCCTCurveTests/CurveTestFixtures.swift, following the Tests/OCCTShapeHealingTests/ShapeHealingTestFixtures.swift precedent. No public API changes: same types, same signatures, same module, just relocated. @Test count unchanged: 555 before, 555 after.

OCCTIOTests.swift split into one file per @Suite (#1302)

Tests/OCCTIOTests/OCCTIOTests.swift carried 5740 lines across 44 @Suite structs, the only file in the target. Split one file per suite, named after the struct, matching the sibling Issue*Tests.swift files in every other domain test target. Shared fixtures used by 2+ suites (invalidBowtieShape(), the SIMD3 .normalized helper, and the #795 golden-drawing fixture) moved to a new IOTestFixtures.swift, matching the Tests/OCCTShapeHealingTests/ ShapeHealingTestFixtures.swift precedent. No public API changes: same types, same signatures, same module, just relocated.

Tests/OCCTAnalysisTests/OCCTAnalysisTests.swift split by @Suite (#1309)

OCCTAnalysisTests.swift carried 143 @Suite structs across 7,376 lines, the largest file in the repo. Split into one file per suite, named after the struct, matching the sibling Issue*Tests.swift files already in the directory. The shared SIMD3.normalized helper moves to a new AnalysisTestFixtures.swift. No public API changes: same tests, same module, just relocated; @Test count unchanged (547 before and after).

OCCTTopologyTests.swift split by @Suite, one file per suite (#1304)

OCCTTopologyTests.swift carried 106 @Suites across 5,256 lines. Split into 106 files, one per suite, named after the struct, matching the sibling Issue*Tests.swift files already in Tests/OCCTTopologyTests/. The file-scope SIMD3.normalized helper (unused within this target) moves to a new TopologyTestFixtures.swift, matching the ShapeHealingTestFixtures.swift precedent. No public API changes: same types, same signatures, same module, just relocated. @Test count is unchanged (556 across the directory, before and after). No Package.swift change needed, its path: already covers the whole directory.

OCCTMathTests.swift split by @Suite, one file per suite (#1306)

OCCTMathTests.swift carried 100 @Suite structs across 3557 lines. Split into 100 per-suite files, each named after its struct (matching the sibling Issue640MathDimensionBoundsTests.swift convention already in the directory), plus MathTestFixtures.swift for the one file-scope helper (SIMD3.normalized, unused by any suite today and not owned by one, so it isn’t moved with a particular suite). No public API changes: same types, same signatures, same module, just relocated. @Test count unchanged (352 across the directory, before and after).

OCCTGeom2dTests.swift split into 110 per-suite files (#1298)

Tests/OCCTGeom2dTests/OCCTGeom2dTests.swift (5,120 lines, 110 @Suite structs) split into one file per suite, matching the sibling Issue*Tests.swift convention already in that directory. A new Geom2dTestFixtures.swift holds the one file-scope declaration shared across the original file (an unused SIMD3.normalized extension), following the ShapeHealingTestFixtures.swift precedent. No public API changes: internal test reorganization only, same tests, same module, just relocated.

OCCTBRepGraphTests.swift split by @Suite (#1303)

Tests/OCCTBRepGraphTests/OCCTBRepGraphTests.swift carried 4496 lines across 64 @Suite structs. Split into one file per suite, named after the struct, matching the sibling Issue*Tests.swift files already in the directory. The shared SIMD3.normalized helper (declared once per module, not currently called by any suite in this target) moves to a new BRepGraphTestFixtures.swift, matching the ShapeHealingTestFixtures.swift precedent. Verbatim move: no assertion, tolerance, or test body changed, and the @Test count is unchanged (226, before and after).

OCCTShapeHealingTests.swift split by @Suite into 78 files (#1301)

Tests/OCCTShapeHealingTests/OCCTShapeHealingTests.swift carried 2,984 lines across 78 @Suite structs. Split one file per suite, named after the struct, matching the sibling Issue*Tests.swift files already in the directory. The file-scope SIMD3.normalized helper (CLAUDE.md’s Test Layout section documents it as the one shared helper per target) moved into the existing ShapeHealingTestFixtures.swift. No public API changes: same types, same signatures, same module, same test count (326 before and after), just relocated.

OCCTDrawingTests.swift split by @Suite into per-suite files (#1299)

OCCTDrawingTests.swift carried 2780 lines across 33 @Suite structs. Split into one file per suite, named after the struct, matching the sibling Issue*Tests.swift files already in Tests/OCCTDrawingTests/. The one file-scope helper used by no suite in this file (SIMD3.normalized, kept for parity with every other domain test target’s own copy, per CLAUDE.md’s Test Layout) moves to a new DrawingTestFixtures.swift, matching the Tests/OCCTShapeHealingTests/ShapeHealingTestFixtures.swift precedent. The one helper used by a single suite (RecordingSink) moves with it, onto DrawingTransformUnificationTests.swift, private -> fileprivate. OCCTDrawingTests.swift itself is now empty and removed. No public API changes: same types, same signatures, same module, just relocated. @Test count is unchanged (197 before and after).

Refman coverage audit, Pass 4d: Mesh, presentation and misc lane (#814)

The largest of #807’s lane audits: 368 headers across nine packages (BRepMesh_, Poly_, IMeshData_, IMeshTools_, AIS_, Graphic3d_, Image_, StdPrs_, StdSelect_). 45 classes were already wrapped or documented; the other 320 are now recorded in docs/occtswift-wrapping-gaps.md across 32 family-level buckets (mostly OCCT’s own OpenGl-based live-viewer pipeline and its own internal meshing-engine machinery, neither reached by this project’s Metal renderer or its BRepMesh_IncrementalMesh entry point). One real, narrow gap recorded rather than wrapped: Poly_TriangulationParameters (a triangulation’s own record of the deflection/angle/minSize it was built with, never read or written by this bridge).

Twelve documentation errors found and fixed: eleven doc lines across docs/reference/ Document-Mesh-Fixing.md and docs/reference/Document.md cited Poly_Triangulation for methods actually implemented by RWMesh_FaceIterator or TDataXtd_Triangulation; docs/API_REFERENCE.md’s PointCloud Swift type is now correctly described as having no OCCT class backing it at all (previously wrongly attributed to AIS_PointCloud).

StdPrs_ (28 classes, OCCT’s default presentation-builder toolkit) is confirmed genuinely unwrapped and undocumented; StdSelect_’s two already-wrapped classes are #809’s own Swift surface, not re-derived here.

Deduplicated writeSTEP(progress:)/writeIGES(progress:) dispatch (#1231)

Exporter.writeSTEP(shape:to:progress:) and writeIGES(shape:to:progress:) now share one private writeWithProgress helper instead of reimplementing the identical validate/dispatch/ translate body twice. No public API or behavior change.

Exporter: deduplicated the *Data temp-file round trip (#1230)

stlData, stepData, igesData and brepData reimplemented the identical “write to a temp file, read it back as Data, clean up” five-statement pattern four times. Extracted into one private Exporter.dataViaTempFile(extension:write:) helper. No public API or behavior change: same signatures, same throws contracts, same output (confirmed byte-identical for all four formats, see “Notes for the reviewer” below).

DXFError/PDFError’s dead .drawingEmpty case removed (#1229)

DXFError and PDFError no longer declare a .drawingEmpty case. Neither writer’s write(to:) has ever thrown it – both already write a valid, empty DXF/PDF file for an empty Drawing, silently, matching SVGWriter’s own (documented, tested) behavior. SVGError never had the case in the first place. A consumer with an exhaustive switch over either enum must remove the .drawingEmpty arm; there is nothing to migrate to, since the case was never produced.

PDFWriter/SVGWriter share one per-layer dash-pattern table (#1228)

PDFWriter and SVGWriter each independently maintained a switch statement over the identical per-layer dash lengths (HIDDEN: 3/2mm, CENTER: 8/2/2/2mm), formatted into each format’s own syntax. A new internal dashLengths(for:) in DrawingDispatch.swift now owns those values once, alongside the sibling strokeWidthMM(for:) table #795 centralized for the same reason; each writer’s dash formatting is now a two-line wrapper around it. No public API change, no behavior change.

Exporter: 8 of 15 Shape-taking export functions were missing the isValid guard (#1226)

writeIGES(shape:to:unit:), writeIGESBRep, writeIGES(shapes:to:), writePLY(shape:to:deflection:normals:colors:texCoords:), writeSTEP(shape:to:modelType:), writeSTEP(shape:to:modelType:tolerance:), writeSTEPCleanDuplicates, and writeGLTF now throw ExportError.invalidShape for an invalid shape before attempting the write, matching every other Exporter write method. Previously: the three STEP overloads and the PLY-with-options overload had no validity check anywhere in the call chain and silently exported an invalid shape; writeGLTF had no guard of any kind, Swift-side or bridge-side; the two IGES overloads (unit:, BRep mode) were already rejected by the bridge’s own check but surfaced as .exportFailed instead of .invalidShape; writeIGES(shapes:to:) silently dropped an invalid shape from a batch and exported the rest instead of rejecting the call. The shared guard is now a single private helper (Exporter.validateExportInputs(shape:url:)) so a future overload can’t drop it the same way.

DXFWriter/PDFWriter/SVGWriter share one entity-buffer implementation (#1227)

DXFWriter, PDFWriter and SVGWriter each independently declared the same five entity arrays (lines/polylines/circles/arcs/texts) and the same addLine/addPolyline/addCircle/ addArc/addText/entityCounts staging logic, byte-for-byte identical apart from a cosmetic arc tuple field-name difference. A new internal DrawingEntityBuffer type now owns that storage and staging once, shared via a DrawingPrimitiveSink protocol-required property (mirroring the existing cachedPrimitiveOps pattern); each writer’s public methods are unchanged in signature and behavior and now forward into it in one line. No public API change.

writeSTLBinary/writeSTLAscii no longer silently drop every face but the first (#1225)

Shape.writeSTLBinary(to:deflection:)/writeSTLAscii(to:deflection:) used to return true after writing only the first face’s triangulation for any multi-face shape (a box, a cylinder, a filleted part), silently discarding the rest. Both now delegate to the same whole-shape StlAPI_Writer-based writer Exporter.writeSTL already uses, so every face is written.

Shape.shadedMesh/Shape.edgeMesh overload pairs share one deinterleave implementation (#1224)

shadedMesh(deflection:)/shadedMesh(drawer:) and edgeMesh(deflection:)/edgeMesh(drawer:) no longer independently reimplement the same mesh-buffer deinterleave and construction logic. Each pair now converges on a shared private helper (buildShadedMeshData(from:) / buildEdgeMeshData(from:)), differing only in which bridge call populates the buffer, mirroring the delegation OCCTShapeGetShadedMeshWithDrawer/OCCTShapeGetEdgeMeshWithDrawer already use one layer down. No public API or behavior change. Elevated from #796’s census by #388 (Pass 4d of #377).

Refman coverage audit: OCAF persistence and format drivers, family-level (#983)

Pass 3c of the refman-coverage epic (#807): audited the 38-package, 342-class OCAF persistence/format-driver layer against the pinned refman. 9 classes are individually wrapped or documented (the eight format-registration classes the bridge names, plus PCDM’s package header); the other 333 are curated in thirteen family-level buckets in docs/occtswift-wrapping-gaps.md, per the lane’s own predicted shape (“an attribute driver is not a callable capability, it is what makes an attribute survive a round trip”). One genuine, narrow under-coverage finding recorded: StdDrivers_/StdLDrivers_ register two legacy, read-only OCAF formats ("MDTV-Standard", "OCC-StdLite") that Document never registers. Two stale claims found in docs/thread-safety.md’s #349/#353/#374 writeup (a superseded fix mechanism and a removed suppression it still describes as current) were filed as #1232 rather than fixed in this PR, since a human was concurrently working in that same file. Census artifact: Scripts/repro/983-ocaf-persistence-drivers/.

OCAF framework layer audited against the pinned refman, in both directions (#982)

Pass 3b of #807. Five OCCT packages (TFunction_, TPrsStd_, TObj_, AppStd_, AppStdL_), 51 classes, compared against occt-refman@8.0.1 and the pinned headers.

Under-coverage. 42 of the 51 were neither wrapped nor documented and none carried a recorded reason. docs/occtswift-wrapping-gaps.md gains an OCAF-framework-layer section covering all 42, grouped by measured mechanism: 8 collection aliases deprecated at file scope since OCCT 8.0.0, 4 classes requiring an application-specific subclass (protected constructor or pure-virtual method, each confirmed directly), 17 TObj_ classes that are internal machinery of that same subclassing framework, 10 TPrsStd_ classes that populate an AIS_InteractiveObject through OCCT’s own live-viewer pipeline (AIS_InteractiveContext/V3d_Viewer, confirmed unreferenced anywhere in this bridge or its docs: OCCTSwift’s display layer is Metal instead), and 2 legacy TDocStd_Application resource-name subclasses superseded by #371’s direct instantiation. The 42nd, TFunction_Iterator, is a genuine capability gap recorded as one rather than squeezed into a curated excuse: it walks the regeneration dependency graph in execution order, needs no subclass, and is never constructed anywhere in this bridge despite being #included.

Over-coverage. 2 findings, both fixed. docs/reference/Document-XCAF-Notes.md attributed TObjApplication.createDocument() to TObj_Application::NewDocument, a real but different method, inherited from TDocStd_Application and never called; the bridge actually calls TObj_Application’s own CreateNewDocument override. Neither census-doc-occt-attribution.py nor this pass’s own method-attribution checker can catch that shape (the cited method genuinely exists, just isn’t the one reached), so it was found reading the header directly. The same doc also attributed DriverTable.initStandard() to TPrsStd_DriverTable::Get + “TPrsStd_AISPresentation standard driver registration”; InitStandardDrivers()’s own body registers six TPrsStd_Driver subclasses and never touches TPrsStd_AISPresentation at all, the textbook shape census-doc-occt-attribution.py --lane is built to catch, and did.

Artifact. Scripts/repro/982-refman-coverage-ocaf-framework/: the by-call lane derivation, the census with a self-test and a family-count assertion, and a removal matrix that proves each detector shape load-bearing (and found one design inconsistency of its own on first run: a declares_member propagation branch inherited from #812’s template with no case in this lane that could ever exercise it, removed rather than left unproven).

Drawing/2D-annotation lane audited against the pinned refman, in both directions (#812)

Pass 4b of #807. Three OCCT packages (HLRAlgo_, HLRBRep_, HLRAppli_), 93 classes, compared against occt-refman@8.0.1 and the pinned headers. Prs3d_ contributes zero classes: the only construction sites are behind DisplayDrawer.swift’s 3D Metal-display tessellation control, not this lane’s 2D output.

Under-coverage. 86 of the 93 were neither wrapped nor documented and none carried a recorded reason. docs/occtswift-wrapping-gaps.md gains a Drawing/2D-annotation-lane section covering all 86, grouped by measured mechanism: 2 bare package-utility classes, 15 collection aliases deprecated at file scope since OCCT 8.0.0, 5 alias templates to GeomLProp_*Base instantiations, 1 header declaring no class of its own name, 1 unread bit-flag enum, and 62 internal engine helpers across four sub-mechanisms (16 poly-HLR internal mesh/edge-status data, 21 exact-HLR internal edge/face/interference cursor state, 7 template-policy “Tool” adaptors, 18 HLRBRep_The<X>Of<Y>/My<X>Of<Y> curve/curve and curve/surface intersection-engine template instantiations). Unlike #811’s lane, almost none of the 86 is a genuine capability gap: HLR is one algorithm with five public entry classes and the rest is its own internal machinery.

Over-coverage. 0 findings. census-doc-occt-attribution.py --lane surfaced 5 candidates at 3 locations, all in docs/reference/Drawing.md, all one shape (HLRBRep_HLRToShape/HLRBRep_PolyHLRToShape attributed to OCCTDrawingGetEdges) and all rejected on read: the doc’s “selected via OCCTEdgeType in OCCTDrawingGetEdges” phrasing accurately describes a two-function mechanism (a sibling function extracts the compounds, this one selects among them), not a false claim about which function constructs either class. A hand read of every remaining - **OCCT:** bullet touching this lane (HLRAlgo_Projector’s constructors, HLRBRep_TypeOfResultingEdge’s six ordinals, HLREdgeCategory’s eleven cases, both reflectLines* descriptions) found nothing further.

Artifact. Scripts/repro/812-refman-coverage-drawing/: the by-call lane derivation, the census with a self-test and a family-count assertion, and a removal matrix that proves each detector shape load-bearing.

emitOrdinate’s dx/dy leader/tick/tolerance-text recipe unified into one axis-generic helper (#1192)

emitOrdinate (DrawingDispatch.swift) drew its per-feature X and Y extension-leader/tick/ tolerance-text geometry as two independent, axis-swapped copies of the same recipe. Both now share a new emitOrdinateAxisFeature(...) (private, parametrized by which axis is “along”). No output values changed, confirmed byte-identical against the existing DXF/SVG/PDF golden-output tests.

DrawingDimension.Radial/.Diameter share one Circular payload struct (#1185)

Radial and .Diameter were two field-for-field-identical structs (7 fields + memberwise init), hand-duplicated across six switch-arm pairs in DrawingAnnotation.swift, DrawingComposition.swift and Drawing.swift, and already drifted (Diameter.leaderAngle had lost the doc comment Radial.leaderAngle still carried). Both are now public typealiases of a new DrawingDimension.Circular, which holds the shared fields once. Existing code compiles unchanged (.radial(...), .diameter(...), DrawingDimension.Radial(...), .Diameter(...), field access, DrawingDimension.value all work exactly as before).

Breaking: DrawingDimension.Radial.value and .Diameter.value (the struct-level computed properties) are removed; radius/2 * radius now compute only inside DrawingDimension.value’s own switch (unchanged output there). Read dimension.value on the DrawingDimension enum case instead of .value on a bare Radial/Diameter struct.

Four independent scale * p + translate transform sites unified, TransformedDrawing.apply(_:) is no longer dead code (#1183)

DrawingDimension.transformed, DrawingAnnotation.transformed (both DrawingComposition.swift) and collectProjectedEdges (DrawingDispatch.swift) each independently re-derived the 2D affine transform scale * p + translate in a local closure, while TransformedDrawing.apply(_:) – documented as the canonical implementation – had no callers at all. All four now share a new TransformedDrawing.apply(_:translate:scale:) (internal static). No output values changed. Public API unchanged (TransformedDrawing.apply(_:) keeps its existing signature and behavior).

Six independent 2D left-perpendicular derivations unified into leftPerpendicular2D(of:) (#1182)

emitLinear/emitRadial/emitDiameter (DrawingDispatch.swift), breakLine (DrawingSymbols.swift), cosmeticThreadSideView (DrawingThreadAnnotation.swift), and arrowheadBasePoints (DrawingStyle.swift, itself already shared by emitCuttingPlaneLine and datumFeature since #1173) each independently re-derived the 2D “rotate 90° counter-clockwise” perpendicular of a direction vector, some as SIMD2(-d.y, d.x) on a named vector and some as the trig-form equivalent SIMD2(-sin(θ), cos(θ)); the latter is why a prior audit pass missed two of them. All six now share a new leftPerpendicular2D(of:) helper (DrawingStyle.swift, internal), distinct from the unrelated 3D perpendicularBasis(to:) (#881). No output values changed. Internal only.

Sheet/ProjectionSymbol/StandardLayout can now render onto PDFWriter and SVGWriter, not just DXFWriter (#1180)

Sheet.render(into:), ProjectionSymbol.render(_:at:into:), and StandardLayout.render(into:) gain PDFWriter and SVGWriter overloads alongside their existing DXFWriter one. Previously a Sheet’s ISO 5457 border, ISO 7200 title block, and ISO 5456-2 projection symbol, and a standardLayout(of:) result’s placed views, could only be emitted onto a DXFWriter; a PDF or SVG sheet had to be composed without any of that scaffolding. All three writers already implement the underlying primitives identically, so PDF/SVG output is pixel-for-pixel the same scaffolding DXF has always drawn, just staged onto a different writer.

let sheet = Sheet(size: .a4, title: TitleBlock(title: "Bracket", drawingNumber: "B-001"))
try Exporter.writePDF(sheet: sheet, to: url) { pdf in
    sheet.render(into: pdf)          // now works -- used to only accept DXFWriter
}

No existing signature changes; the DXFWriter overloads are unchanged.

ISO 6410 cosmetic-thread end-view arcs now reach PDF/SVG and Drawing.bounds(), not just DXF (#1179)

DrawingAnnotation.cosmeticThreadEndView(centre:majorDiameter:pitch:) used to return a bespoke ArcSegment type that was never a DrawingAnnotation, reachable only through DXFWriter.addCosmeticThreadEndView’s own bypass of the shared annotation dispatch. It now returns [DrawingAnnotation] (three .arc(DrawingAnnotation.Arc) cases), a full DrawingAnnotation factory like its sibling cosmeticThreadSideView. A new Drawing.addCosmeticThreadEndView(centre:majorDiameter:pitch:) stores the arcs on a Drawing, so a thread end-view now exports identically to DXF, PDF and SVG, and participates in Drawing.bounds()/detailView(). DXFWriter.addCosmeticThreadEndView is unchanged in behaviour.

Migration: any caller reading DrawingAnnotation.ArcSegment (removed) or the old [ArcSegment] return of cosmeticThreadEndView should switch to pattern-matching case .arc(let a) = annotation, reading a.centre/a.radius/a.startAngle/a.endAngle (same fields, plus a new layer/id). Any exhaustive switch over DrawingAnnotation needs a new .arc arm.

Arrowhead/triangle-pointer geometry unified between cutting-plane-line arrows and datum-feature triangles (#1173)

emitCuttingPlaneLine’s arrowhead and datumFeature’s triangle pointer independently re-implemented the same “unit direction, its perpendicular, two base points offset by a half-width from a point set back along that direction” vector geometry with unrelated naming and independently-chosen proportions (a 0.4x shouldered arrowhead vs. a 1.0x full wedge). Both now share a new arrowheadBasePoints(apex:direction:backset:halfWidth:) helper (DrawingStyle.swift, internal); each site still supplies its own proportions, since the two are legitimately different symbols, but the shared point-offset math now lives in one place. No output values changed. Internal only.

Polygon hatch-fill’s duplicated implementations unified onto Hatch_Hatcher (#1172)

Drawing.addHatch’s rendering path (emitHatch, used by every DrawingWriter: DXF/SVG/PDF) reimplemented, in hand-rolled Swift, exactly what HatchPattern.generate’s OCCT-native Hatch_Hatcher call already does, and the two had drifted: a bare, five-orders-looser near-horizontal tolerance (1e-12 vs Hatch_Hatcher’s own 1e-7), no allocation bound on emitHatch’s scanline output, and HatchPattern.generate had no way to trim against island (hole) polygons at all even though emitHatch already supported them. OCCTHatchLines now accepts an optional flattened array of island polygons and Trim()s the hatcher against each island edge, same even/odd rule as the outer boundary; HatchPattern.generate gains a matching islands: parameter (default [], existing calls unaffected); and emitHatch is now a thin wrapper over HatchPattern.generate instead of a second implementation, inheriting Hatch_Hatcher’s tolerance, island support and allocation bound.

Deduplicate OCCTDrawingGetEdges’s eight guard-then-add sites (#1190)

  • New shared occtAddShapeIfPresent helper in OCCTBridge_Internal.h
  • OCCTDrawingGetEdges (OCCTBridge_HLR.mm) now calls it at all eight sites instead of reimplementing if (!x.IsNull()) { builder.Add(compound, x); } per field
  • Internal bridge refactor only, no behavior change

Drawing.addCuttingPlaneLine’s duplicated direction-projection recipe de-duplicated (#1193)

addCuttingPlaneLine’s trace and arrow direction blocks each projected a 3D direction as projectPointToPlane(direction, ...) - projectPointToPlane(.zero, ...), a roundabout point-difference idiom that only produced the right answer because projectPointToPlane has no translation term of its own; a future change to projectPointToPlane/perpendicularBasis picking up an affine origin would have silently broken both blocks with nothing to catch it. Both now share a new projectDirectionToPlane(_:viewDirection:) helper (a direct simd_dot against perpendicularBasis(to:)’s (right, up)), and projectAxisToPlane’s equivalent dir2 computation now routes through the same helper too, so there is one canonical “project a direction into the view plane” recipe instead of three. No output values changed. Internal only.

Add DrawingAnnotation.rectangleCentrelines(min:max:style:) helper, dedupe FCF/datum boxes (#1188)

  • New public function DrawingAnnotation.rectangleCentrelines(min:max:style:), building the four .centreline edges of an axis-aligned rectangle on top of rectanglePoints(min:max:)
  • featureControlFrame’s outer box and datumFeature’s label box both now call it instead of hand-rolling the same four .centreline appends
  • No behavior change: identical winding order, style and geometry at both call sites

Added packSIMD3, the write-direction sibling of unpackSIMD3, and deduplicated PointCloud’s pack loops onto it (#1186)

Unify LengthDimension/RadiusDimension/AngleDimension/DiameterDimension behind shared DimensionMeasurement base (#1178)

Add rectanglePoints(min:max:) helper for CCW rectangle corners (#1189)

  • New public function rectanglePoints(min:max:) in DrawingSheet.swift
  • Replaces 4 hand-built corner arrays with single shared helper
  • Ensures consistent CCW winding order across all rectangle uses

Deduplicate HLR bridge 6-field drawing population (#1184)

  • Extracted occtDrawingPopulate template helper in OCCTBridge_HLR.mm
  • Both OCCTDrawingCreate and OCCTDrawingCreatePoly now use the shared helper
  • Reduces code duplication and ensures consistent field population

Rename 2D DrawingAnnotation.TextLabel to DrawingTextLabel (#1175)

  • Renamed DrawingAnnotation.TextLabel struct to DrawingAnnotation.DrawingTextLabel
  • No public API surface change: Drawing.addTextLabel() and the .textLabel enum case remain unchanged
  • Internal references updated automatically via enum case inference

Remove duplicate ISO 128-20 line-width table; unify on strokeWidthMM (#1170)

  • Deleted DrawingLineStyle.defaultWidth and boldWidth computed properties (dead code)
  • strokeWidthMM(for:) in DrawingDispatch.swift is now the canonical line-width table
  • Tests updated to verify strokeWidthMM values match ISO 128-20

Fix surfaceFinish machiningProhibited to emit circle as centreline segments (#1177)

  • .machiningProhibited case now produces 24 connected .centreline segments forming a circle
  • Removed the text label “O” fallback that was previously emitted
  • Consistent with datumFeature (triangle as 3 lines) and breakLine (zigzag as 5 lines)

Remove shadowing compound(from:) and use compound(_:) in Section2D (#1171)

refactor(drawing): share cosmetic thread minor-diameter formula (#1187)

refactor(drawing): unify circle-visibility test between addAutoDimensions and addAutoCentermarks (#1181)

Fix false GDTSymbol doc comment claiming round-trip with GeomToleranceType (#1176)

#1174 HLR bridge deflection doc omission

Curve2D.swift’s Gcc/analytic-intersection/extrema families split into their own files (#687)

Curve2D.swift carried 962 lines across 13 declarations belonging to other type families. Split out:

  • Curve2DGcc.swift: Curve2DGcc, Curve2DQualifier, Curve2DCircleSolution, Curve2DLineSolution, Curve2DHatchSegment, BisecType, BisecSolution, GccAnaBisector
  • IntAna2d.swift: Intersection2DPoint, IntAna2d
  • Extrema2d.swift: Extrema2DResult, Extrema2d

Surface.swift’s CurveSurfaceIntersection and its Curve3D intersection extension move onto Curve3D.swift. No public API changes: same types, same signatures, same module, just relocated. BRepGraph.swift and Curve3D.swift were re-measured and confirmed to need no split of their own.

Bridge surface Swift couldn’t reach: BRepGraph occurrence placements now readable, two orphan functions resolved (#1010)

Three pieces of bridge surface no Swift code could reach or observe:

  • BRepGraph.shape(nodeKind: .occurrence, nodeIndex:) now applies the occurrence’s placement to the returned shape. New readers occurrenceRefLocalLocation(_:) / childRefLocalLocation(_:) (plus five sibling getters for vertex/coedge/wire/face/shell/solid refs, which OCCT 8.0.0p1 never stores a location for and so always return nil) make the four previously write-only placement setters testable at all.
  • OCCTBRepExtremaExtCCEdges, an orphaned bridge function with no Swift caller, is now exposed as Shape.edgeEdgeExtrema(edge1:edge2:): edge-edge distance extrema between two standalone edge shapes, without needing indices into a parent shape.
  • OCCTWireMakeWireFromEdges, the other orphan, is removed: it overlapped Wire.wireFromEdges(_:) and Shape.wireFromEdges(_:), both already wrapped.

Also fixed along the way: kindFromInt() (the bridge’s int32_t -> BRepGraph_NodeId::Kind translator, shared by 22 call sites) never had cases for the Product/Occurrence node kinds added for the ProductOps assembly-building surface; both silently fell through to Kind::Solid. Nothing before this PR called it with those kinds from Swift, so the gap was latent until the new occurrence-shape reader exercised it.

Fixed Shape.isSelfIntersecting(hardTimeout:) probe clones geometry, not just topology (#1160)

Shape.isSelfIntersecting(hardTimeout:)’s background probe now clones geometry (copyGeometry: true), not just topology, removing a latent BSpline-adaptor-cache race with the caller’s continued use of the original shape.

Fixed Sheet.inset A4 margins to match ISO 5457 doc comment (#1168)

The .a4 arm was returning (20, 10, 10, 10) instead of (7, 7, 7, 10) per the doc comment.

Compute qualifier for GCC 2D tangent solvers and intersection parameters (#781)

Shape.withPrism/withBoss/withPocket documentation corrected (#1047)

The methods were documented as feature-based operations but actually use extrusion + boolean. Section renamed to “Extrusion-Based Features”, doc comments updated to name BRepPrimAPI_MakePrism + BRepAlgoAPI_Fuse/Cut and cross-reference prismUntilFace for true feature prisms.

Teach check-null-handle-guards.py to detect OCAF handle dereferences from FindAttribute / GetObject patterns (#1052)

  • New OCAF_DEREF_RECEIVERS table listing OCAF attribute types (XCAFDoc_Datum, XCAFDoc_Dimension, XCAFDoc_GeomTolerance) and their methods that internally dereference OCAF-fetched handles (GetObject).
  • New regexes OCAF_FINDATTRIBUTE and OCAF_HANDLE_DECL to detect handle acquisition via label.FindAttribute(Type::GetID(), handle) and declare-then-assign forms.
  • New OCAF_GUARD_HELPERS named-helper allowlist (occtDatumLabelIsReadable, occtDocumentGdtAlwaysReadable) for structural label guards instead of IsNull().
  • Self-test fixtures for both unguarded and guarded OCAF patterns (declare-then-assign and direct init forms).

Fix: derive-gdt-enums.py now fails on unknown GD&T enums (#1063)

The --reverify-headers mode now treats any XCAFDimTolObjects_* enum header not listed in BOUND or KNOWN_UNBOUND as a hard error. This prevents a new OCCT enum from being silently ignored by the gate.

Add regression tests for naming trace iterator behavior (#950)

Unify OBJ/PLY XDE export pipeline via shared helper occtExportCafImpl (#976)

  • Add occtExportCafImpl template in Sources/OCCTBridge/src/OCCTBridge_IO.mm
  • Refactor OCCTExportOBJ and occtExportPLYImpl to use shared helper
  • Net reduction: 13 lines (30 added, 43 removed)

Fix Construction.md: correct basis algorithm docs, remove deleted helper reference, add Placement.lift entry (#1060)

Document Edge.split(at:vertex:) out-of-range refusal and fix incorrect caveat (#1061)

Fix doc comment discrepancies for Curve2D polynomial pole count and GD&T plural accessors (#1062)

Fix #1085: bisectorIntersections validates non-finite and large coordinates (#1085)

Adds BisectorIntersection.maxSafeMagnitude (1e150) threshold and early validation in bisectorIntersections to reject NaN, ±Infinity, and coordinates exceeding the safe magnitude. Includes comprehensive test suite Issue1085BisectorNonFiniteTests.

SAWireAnalysis check functions now return Bool? so refusal is distinguishable from a clean verdict (#1074)

The ten whole-wire checks (checkOrder, checkConnected, checkSmall, checkDegenerated, checkClosed, checkSelfIntersection, checkGaps3d, checkGaps2d, checkEdgeCurves, checkLacking) and four per-edge checks (checkConnectedEdge, checkSmallEdge, checkDegeneratedEdge, checkGap3dEdge) now return Bool? instead of Bool. A nil return means the check could not run (wrong type, null shape, edgeless wire, or unassemblable wire). The pcurve guard remains specific to checkOuterBound.

bisectorIntersections(a:b:c:d:) now reports segment endpoints for coincident bisectors (#1070)

Two coincident half-lines (the same bisector constructed from identical or collinear point pairs) previously returned an empty array because OCCT reports their intersection as a segment, not a point. The bridge now reads the segment endpoints and returns them: the shared midpoint (parameter 0 on both rays) and the point at infinity (Precision::Infinite()).

// Before: [] (indistinguishable from "no crossing")
// After: 2 points, the midpoint and the point at infinity
let coincident = bisectorIntersections(a: (0, 0), b: (4, 0),
                                        c: (0, 0), d: (4, 0))
// coincident.count == 2
// coincident[0] == (2, 0), paramOnFirst == 0
// coincident[1].paramOnFirst > 1e99 (≈ Precision::Infinite())

Consolidate three near-identical GD&T label-lookup helpers (#1065)

Document investigation concluding #345 SIGABRT was not caused by #1057 toolchain defect (#1072)

Fix inventory mismatches in ci.yml and carried-occt-patches.md (#1066)

  • ci.yml: update gate script count from “five” to “eleven” with accurate breakdown
  • okf/references/carried-occt-patches.md: fix patch 0027 row key to match actual filename

Fix OCCTBridge.h class index for OCCTWireCheckOuterBound (#1075)

Fix #1064: Strengthen three Pass 4a test suites to avoid false positives and exercise all parameters (#1064)

  • Issue1009Matrix12GroupedTests: Added second discriminator for translation test to distinguish success from the bridge’s error sentinel.
  • Issue999NLPlateParametersTests: Added g2ToleranceIsLive and g3ToleranceIsLive tests proving the tolerance parameter is live.
  • Issue1017NLPlateResolutionOrderTests: Strengthened inRangeOrderStillBuilds to verify actual deformation (maxAbsZ > 1.0) not just non-nil return.

Consolidate BRepCheck tri-state decoder into shared helper; add census of tri-state return functions (#1077)

  • New shared helper occtBRepCheckSubShapeStatus in OCCTBridge_Internal.h consolidates logic previously duplicated in OCCTCheckFaceStatus, OCCTCheckEdgeStatus, OCCTCheckVertexStatus
  • Added docs/tri-state-census.md documenting 15+ tri-state int32_t functions across Healing, Modeling, Properties, Document, and Advanced Modeling domains
  • No behavioral change; all 5922 tests pass, all 8 gate scripts pass

Fixed Shape.BooleanOperation raw values to match OCCT’s BOPAlgo_Operation enum (#1082)

The Swift BooleanOperation enum cases common and fuse had transposed raw values (0/1) compared to OCCT’s BOPAlgo_Operation (COMMON=0, FUSE=1). The bridge’s explicit switch was masking this mismatch. Now the enum values match directly and the switch is removed.

OCCTBridge_Modeling split into HLR and AdvancedModeling domains (#1071)

The monolithic OCCTBridge_Modeling.h/.mm (62K lines) is split into three domain-specific files:

  • OCCTBridge_HLR.h/.mm: Hidden Line Removal / Drawing (HLRBRep, HLRAlgo, BRepMesh)
  • OCCTBridge_AdvancedModeling.h/.mm: Filleting, drafting, defeaturing, pipe sweeps, thread cutters, B-spline/ruled surfaces, shell-with-open-faces
  • OCCTBridge_Modeling.h/.mm: Remaining core modeling (booleans, offsets, sweeps, features, healing, etc.)

No public Swift API changes. The C bridge headers are re-exported through OCCTBridge.h, so existing imports continue to work. Internal only.

Deflection validation added to OCCTDrawingCreatePoly (#1130 review)

OCCTDrawingCreatePoly now validates deflection > 0 before passing to BRepMesh_IncrementalMesh, returning NULL for non-positive values instead of risking undefined behaviour.

Internal comment and type safety fixes (#1130 review)

  • Duplicate includes removed from OCCTBridge_AdvancedModeling.mm
  • Misleading comment for OCCTShapeCreateRuled corrected (uses BRepFill::Shell, not BRepFill::Face)
  • occtPipeShellSetMode parameter changed from int32_t to OCCTPipeMode enum for compile-time safety

Bridge string returns report full length, support length query (#1078)

  • OCCTDocumentGetLayerName, OCCTBRepGraphHistoryGetRecordInfo, OCCTUnicodeConvertFromUnicode return int32_t (full length or -1) instead of bool
  • All three support out=NULL, max=0 to query required buffer size
  • Swift wrappers updated: Document.layerName(at:), BRepGraph.historyRecord(at:), UnicodeUtils.convertFromUnicode(_:maxSize:)

Add Hashable conformance to GraphSnapshotError and CylindricalHoleExtent enums (#1076)

Install the pinned clang-format without pip or venv, and document how (#1123)

Scripts/install-clang-format.py installs the clang-format version pinned in Scripts/clang-format-version.txt using nothing but the Python standard library:

Scripts/install-clang-format.py                    # -> /usr/local/bin/clang-format
Scripts/install-clang-format.py --version 22.1.8   # outside a checkout

No pip, venv, curl, unzip or apt. This matters for container images: Debian and Ubuntu split ensurepip out of python3 into a versioned python3.N-venv package, so python3 -m venv fails with “ensurepip is not available”, the unversioned apt install python3-venv it suggests can answer “no installation candidate”, and a newer distro’s PEP 668 separately refuses a plain pip install. A clang-format wheel is a zip holding one static binary, so urllib and zipfile are enough. The sha256 PyPI publishes is verified before the binary is written, the write is staged and renamed so a half-written file is never found on PATH, and the installed binary is run to confirm it reports the expected version.

docs/guides/clang-format-setup.md is new: why the version is pinned (21.1.8 and 22.1.8 disagree on 10 of the 33 enforced bridge files), the three install routes and when each applies, the Debian/Ubuntu ensurepip failure with both of its dead ends, a self-contained one-liner for a container build step that runs before a checkout, and how to verify. Scripts/format-bridge.sh --check is the verification step to put in an image build: it checks the version, that -style=file actually resolved to Sources/OCCTBridge/.clang-format, and that the tree is clean, the middle one mattering because clang-format falls back to LLVM style rather than failing when it finds no config.

One clang-format entry point for the bridge, a pre-commit check, and a pinned version (#1121)

Scripts/format-bridge.sh is the single entry point for formatting the OCCTBridge C++ layer. Run it after any edit to a bridge .h/.mm:

Scripts/format-bridge.sh            # rewrite every enforced bridge file in place
Scripts/format-bridge.sh --check    # report only; exactly what CI and the pre-commit hook run
Scripts/format-bridge.sh One.mm     # just these files

It replaces the file-selection and check loop .github/workflows/code-style.yml carried inline, so CI, the optional pre-commit hook (Scripts/git-hooks/pre-commit, which gained the check) and a local run cannot check different files or different flags. This matters now that Scripts/style-manifest-bridge.txt is empty and all 33 bridge files are enforced: OCCT’s AlignConsecutiveDeclarations/AlignConsecutiveAssignments make two ordinary consecutive locals a violation unless the tool wrote them, and hand-aligning produces something that looks compliant and is not.

The clang-format version is now pinned in Scripts/clang-format-version.txt (22.1.8) and installed from PyPI in CI rather than taken from Homebrew. clang-format’s output changes between major versions: 21.1.8 and 22.1.8 were measured to disagree on 10 of the 33 enforced files, so an unpinned install could turn a green main red on a Homebrew bump with no code change. A clang-format on a different major is refused locally rather than allowed to produce a diff CI rejects; a differing minor or patch only warns.

No bridge source changed: the tree was already clean under the pinned version.

GD&T datum/dimension/tolerance tables unified to the document tool label (0:1:4) (#1051)

The bridge used two separate GD&T tables: the old table at label 0:1 (created via XCAFDoc_DimTolTool::Set(doc->doc->Main())) and the new table at label 0:1:4 (created via XCAFDoc_DocumentTool::DimTolTool()). Swift API writes went to the old table; STEP/OCAF importers wrote to the new table. This caused a split where:

  • Datums created via Swift API (createDatum()) were invisible to dimTolToolToleranceCount / dimTolToolDimensionCount
  • Datums, dimensions, and geometric tolerances from STEP/OCAF imports were invisible to Document.datumCount, Document.dimensions, Document.geomTolerances, etc.

Write path: All Swift API create functions now write to the new table (0:1:4) via XCAFDoc_DocumentTool::DimTolTool():

  • OCCTDocumentCreateDatum
  • OCCTDocumentCreateDimension / OCCTDocumentCreateDimensionWithTolerance
  • OCCTDocumentCreateGeomTolerance

Read path: All read/count functions now search BOTH tables, presenting a unified view:

  • New table entries first (importers + new Swift API writes)
  • Old table entries second (for backward compatibility with existing documents)
  • Updated: OCCTDocumentGetDatumCount, OCCTDocumentGetDimensionCount, OCCTDocumentGetGeomToleranceCount
  • Updated: occtDocumentDatumObjectAt, occtDocumentDimensionObjectAt, occtDocumentGeomToleranceObjectAt

Mutator path: Works through the unified lookup, so modifications apply to the correct table.

Migration: During the transition period, documents created with the old API (data in old table) remain fully readable. New writes go to the new table. The unified read presents all data regardless of which table it lives in. This ensures:

  • Existing OCAF documents written by this package remain readable
  • STEP/OCAF imports work correctly with the Swift API
  • No data loss during migration

Added comprehensive test suite Issue1051GDTTableUnificationTests verifying:

  • Swift API datums visible to both datumCount and dimTolToolToleranceCount
  • Dimension/GeomTolerance counts work correctly
  • Multiple entries counted correctly
  • Mutators work through unified lookup
  • Datum name round-trip
  • Datum target operations

Shape.selfIntersects answers the check instead of the checker, and is deprecated (#1088)

Shape.selfIntersects returned BOPAlgo_CheckerSI::HasErrors(), which is “did this algorithm fail” rather than “did it find an interference”. The result of the check lives in BOPDS_DS::Interferences(), which the bridge function never read.

Measured on three independent constructions of a genuinely self-intersecting shape, two overlapping boxes in a compound, two overlapping spheres, and a bowtie profile swept into a prism: all three returned false. On this evidence the property never returned true for a shape that self-intersects. In the other direction, a clean box the checker records an error for returned true. Nothing in the test suite was affected, because the property’s one call site asks whether a clean box self-intersects, which is the single question the old code happened to get right.

It now reads the interference map, skipping pairs that involve a shape the pave filler created, the same reading BOPAlgo_ArgumentAnalyzer::TestSelfInterferences performs. A null shape is refused rather than passed to BOPAlgo_CheckerSI::Perform, which dereferences it and SIGSEGVs in a standalone process.

Shape.selfIntersects is deprecated, pointing at Shape.isSelfIntersecting(timeout:). The property keeps working. Two reasons it is not the call to reach for: its Bool cannot express “could not answer”, so false means both “clean” and “the check failed”; and it is unbounded, on a check measured at 619 s on one artifact (#319) and at 339 s to 463 s on an ordinary bevel gear (#1054). isSelfIntersecting(timeout:) bounds the work and returns nil for indeterminate.

switch shape.isSelfIntersecting(timeout: 30) {
case .some(true):  break  // heal before booleans
case .some(false): break  // clean
case .none:        break  // unknown, not clean
}

Reproducers, transcripts and the injection matrix are in Scripts/repro/1088-selfintersects-answer/.

A boolean timeout is no longer indistinguishable from a boolean failure (#1067)

subtracting, union and intersection return nil both when the boolean fails and when it exceeds its wall-clock timeout (Shape.defaultBooleanTimeout, 120s). The two mean opposite things: a failure is a property of the geometry, a timeout is a property of the deadline and of whatever else the machine was doing. Reporting the second as the first sends whoever reads the error to inspect a shape that is fine, which is how it surfaced downstream as a gear-construction error under load.

Three new methods report which happened, and the existing three are unchanged:

switch blank.subtractionOutcome(tools) {
case .success(let cut): print(cut.volume as Any)
case .timedOut:         print(blank.subtractionOutcome(tools, timeout: 600).shape as Any)
case .failed:           print("the boolean declined these operands, a longer timeout will not help")
}

unionOutcome, subtractionOutcome and intersectionOutcome take exactly the parameters their named siblings take and return Shape.BooleanOutcome (.success(Shape) / .failed / .timedOut, plus .shape for the collapsed answer). union, subtracting and intersection keep -> Shape? and are now thin wrappers returning .shape, so nothing changes for a caller who does not want the distinction, and +, - and & are untouched. .timedOut is indeterminate, not a negative result, the same contract isSelfIntersecting(timeout:) gives its nil, and it inherits the same caveat that timeout: is cooperative rather than a hard deadline (#293). With timeout: 0 there is no watchdog, so .timedOut cannot occur.

The distinction was already computed and discarded: OCCTBoolTimeoutBreaker::tripped() has existed since #206 and is read by OCCTShapeSelfIntersectsBounded, but not by the booleans. It agrees with OCCT’s own BOPAlgo_AlertUserBreak on 200/200 repeats (Scripts/repro/1067-boolean-timeout-outcome/).

Separately, circularPatternCut gains a timeout: parameter, defaulted, so a caller whose cut is legitimately long can raise a bound that previously applied to it without being reachable from its signature. Its three indistinguishable nil returns (count <= 0, the pattern failing, the subtraction failing or timing out) are now documented, with the two-step decomposition that tells them apart.

Surface.nlPlateDeformed and its four siblings return the deformation where the caller put it (#1049, #1046)

NLPlate_NLPlate::Evaluate returns the absolute deformed point rather than a displacement, so nlPlateDeformed and nlPlateDeformedG1 adding the input surface to it put every result at twice its distance from the origin. A part positioned anywhere but the origin came back at double its offset, and the defect was as old as the bridge function; an origin-centred plane, which is what every shipped fixture was, came back stretched by two in u and v instead. All five entry points now sample Evaluate alone.

The returned surface also carries the parametrisation of the working domain it was sampled over, instead of the [0, 1] x [0, 1] the fit produces, so the (u, v) a constraint was written at addresses the same place on the result. nlPlateDeformedG2, nlPlateDeformedG3 and nlPlateDeformedIncremental additionally sampled a hardcoded [0, 1] x [0, 1] of the input rather than the working domain, and now sample the same domain as the other two: the input surface’s own range in a direction it bounds, and the constraint span padded by 10 in one it does not. A deformed cylinder therefore spans its own [0, 2pi] rather than an interval derived from where its constraints happened to sit.

A periodic input still comes back non-periodic and the 20x20 sample grid is still fixed; both are recorded in docs/occtswift-wrapping-gaps.md. The cookbook’s nlPlateDeformed snippet is corrected too: it passed maxIterations:, the label #1017 renamed to resolutionOrder: in this same unreleased section, so it had not compiled since. The reproducer, the closed-form fixture and the before/after transcripts are in Scripts/repro/1049-nlplate-double-base/.

nlPlateDerivative’s documentation called its result a displacement, which was the same misreading; it is a derivative of the deformed surface. Documentation only, no behaviour change there.

The datum lookup refuses the shape that crashes XCAFDoc_Datum::GetObject (#1030)

XCAFDoc_Datum::GetObject builds the datum point’s X out of the annotation plane’s array instead of the point’s own, so a datum carrying a point with no plane location dereferences a null handle. That is an OS signal, not a Standard_Failure, so the bridge’s catch (...) could not absorb it and the process died. Scripts/patches/0029-* fixes it in the kernel and is in none of the seventeen patches the pinned v3.0.0 asset carries, so nothing protected a consumer.

The bridge now refuses that one shape before calling GetObject, at all three sites that reach it. Document.datum(at:) returns nil and Document.datums omits the datum; the five datum write methods and clearDatumTarget(at:) return false, because the lookup they share runs before any of them can read what it returned; Document.dimTolToolToleranceCount returns 0 and Document.rescaleGeometry(labelId:scaleFactor:forceIfNotRoot:) returns false, since those two reach GetObject on the XCAFDoc_DocumentTool table instead, outside the shared lookup.

A datum carrying both a point and a plane is unaffected and still reads, though the point OCCT builds for it has the wrong X until a rebuilt kernel ships. Nothing on the GD&T write path produces the refused shape: createDatum(name:) sets a name, a position and a modifier pair, never a point. It arrives with an OCAF document or through the label API.

Two defects found while measuring this and filed rather than folded in: the datum accessors and dimTolToolToleranceCount read two different GD&T tables, so a datum an importer wrote is invisible to Document.datums (#1051); and check-null-handle-guards.py is structurally blind to a null handle a kernel method fetches from its own OCAF state (#1052).

Sub-kind 4 for the unmeasured-values census: the subject the caller never fed (#726)

Scripts/census-unmeasured-values.py gains a fourth sub-kind. The first three all key on the shape of an OUTPUT (a literal on the right of an assignment, a pinned .count, a boolean that never flips), so a function that queries a throwaway object it built itself, and returns what that throwaway says, satisfies all three: measured against #1000’s six deleted DraftInfo members, reconstructed from PR #1002’s own diff, sub-kinds 1 and 3 report none of them. Sub-kind 4 keys on the subject instead. It taints the function’s own parameters, grows the set through assignment, call and control-dependence edges, and reports any return, out-parameter write or result field whose expression reads a member of a local no caller input ever reached, plus any function whose only answer is a parameter it reads nowhere else. It catches five of #1000’s six.

The other two Pass 4a instances are measured as out of reach from bridge text rather than assumed to be covered, and both reasons are recorded in the script and in Scripts/repro/726-unfed-subjects/: #999’s OCCTGeomPlateErrors was fed the caller’s own points and tolerance, with the fabrication inside the kernel and its bridge-visible half already reported by detect-dead-parameters.py, and #996’s GD&T reader called three real accessors on the caller’s own dimension object whose zeroes were OCCT answering “not applicable”.

It reports rather than gates, exiting 0 as before, with CI running only its --self-test, now 54 cases against 36. Its bare run over this tree reports 46 candidates in 18 functions and 0 echoes, every one of which adjudicates to “not an instance” against a compiled probe committed alongside: a fresh OSD_MemInfo moves by exactly 67108864 bytes across a 64 MiB allocation, while three consecutive fresh Draft_EdgeInfo instances answer false every time.

No public API change; one census script, its repro directory, and CLAUDE.md’s gate-timing note, since a bare census run is now ~13s where the --self-test stays at 0.5s.

Pass 3: XDE/Assembly duplication audit (#384)

  • Refactor: Extracted shared document initialization boilerplate into occtDocumentInit() helper (used by OCCTDocumentCreate, OCCTDocumentLoadSTEP, and 12 sites in OCCTBridge_IO.mm). Eliminates ~30 lines of duplicated code per pair, adds missing null-check guard to 6 sites in IO.mm.

  • Refactor: Templated naming trace pair (OCCTDocumentNamingTraceForward / OCCTDocumentNamingTraceBackward) on iterator type (TNaming_NewShapeIterator / TNaming_OldShapeIterator). Eliminates ~30 lines of duplicated boilerplate.

  • Refactor: Templated format enumeration pair (OCCTDocumentReadingFormats / OCCTDocumentWritingFormats) on member function pointer. Eliminates ~25 lines of duplicated boilerplate.

  • Refactor: Unified Swift-side format enumeration (readingFormats / writingFormats) and naming trace (tracedForward / tracedBackward) accessors in Document.swift behind private helpers parameterized by the bridge call. Each pair was byte-identical apart from the bridge symbol.

  • Fix: Moved occtDocumentInit to OCCTBridge_Internal.h as inline so it can be used across translation units. All 14 document-creation sites now route through the shared helper. Added missing null-check guard to 6 sites in IO.mm that previously dereferenced doc->Main() without checking IsNull().

  • Internal: Probe confirmed TDocStd_Application::NewDocument("MDTV-XCAF", doc) does not return null on the pinned OCCT 8.0.1 kernel: the six previously unguarded IO.mm sites were not on a latent crash branch.

  • Style: Fixed all 91 swift-format doc comment violations in Document.swift (periods, blank lines, parameter syntax, missing Returns sections). Removed Document.swift from style manifest (now compliant).

Pass 2a: Shape/Topology core duplication audit (#382)

25 findings plus #796’s census resolved, several of them live bugs.

A duplication audit of Shape.swift’s domain-split descendants (Shape+Analysis, +Curve, +Geom2d, +Math, +Mesh, +Modeling, +ShapeHealing, +Surface, +Topology) plus Edge/Face/Wire/WireOrder/TopologyRef (#382, part of the #377 segmented audit). Grouped by what actually changed for a consumer:

Fixed: already-diverged duplicate copies, i.e. real bugs

  • FaceFixer.Status’s raw Int32 values were wrong from .fail1 onward (shifted by one ordinal against the real ShapeExtend_Status enum: .done queried ShapeExtend_FAIL8 instead of the combined DONE flag) since the type was added; corrected. ShapeFixer gains a type-safe status(_ status: ShapeFixStatus) -> Bool overload sharing the corrected type; the legacy status(Int) is unchanged. (#849)
  • Shape.fixed(tolerance:fixSolid:fixShell:fixFace:fixWire:)’s fixShell/fixFace/fixWire parameters were accepted and silently discarded: only fixSolid ever reached ShapeFix_Shape. Wired to FixFreeShellMode()/FixFreeFaceMode()/FixFreeWireMode() (which govern free, unattached content specifically). Existing callers passing non-default values for these three will see different, correct output. (#837)
  • Shape.uniformAbscissa(distance:)/(distance:u1:u2:) had no ceiling at all, unlike their pointCount: siblings: a small enough distance could ask OCCT to discretize an unbounded number of points. Both now derive the implied count from curve length and reject before calling OCCT. (#853)
  • Shape.bounds fabricated (0,0,0)-(0,0,0) for a void shape as if it were real geometry, unlike the identical Shape.boundingBox computation, which correctly signals failure. Bridge hygiene fixed (explicit IsVoid() guard); the full fix (bounds becoming Optional) is a breaking change to 20 call sites and is documented as a candidate follow-up, not executed here. (#834)
  • Shape.droppingSmallEdges(tolerance:) defaulted to 1e-6 against the identical fixSmallEdges(tolerance:dropSmall: true)’s 1e-7, aligned to 1e-7. (#839)
  • Shape.classifyPoint2d(u:v:tolerance:) defaulted to 1e-7 against Face.classify(u:v:)/ Shape.classifyPoint2D’s 1e-6 for the same UV-boundary question, aligned to 1e-6. (#840)
  • Shape.maxTolerance(type:)/minTolerance(type:)/avgTolerance(type:) used an Int encoding incompatible with maxTolerance(subShapeType:)’s real TopAbs_ShapeEnum ordinals (the same literal meant a different sub-shape type under each). Gained additive ShapeType-typed overloads that agree with the ordinal convention; legacy Int overloads unchanged, documented as legacy. The three new bridge functions backing them originally triplicated their body (differing only in a hardcoded mode literal); deduplicated behind one shared helper in the same review pass that caught it. (#833)
  • docs/thread-safety.md/docs/naming-conventions.md incorrectly claimed the no-argument Shape.deepCopy() uses BRepBuilderAPI_Copy with independent geometry. It actually uses TNaming_CopyShape::CopyTool and only clones topology: Geom_Surface/Geom_Curve/ Poly_Triangulation handles are shared with the original. Docs corrected on all three copy methods. Shape.isSelfIntersecting(hardTimeout:)’s reliance on this for background-thread isolation is flagged as a latent risk, not changed. (#831)

Breaking change

  • Selector.SubShapeType.compsolid is renamed compSolid, matching ShapeType’s casing (the two had drifted). Shape.ShapeFilterType’s RawValue changes from Int32 to Int as part of becoming a ShapeType typealias. Migration: rename .compsolid → .compSolid; if code depends on ShapeFilterType’s raw type being Int32, cast explicitly. A third consolidated type, Shape.TopAbs_ShapeEnum, was originally deleted outright with no compatibility path; the only one of the four #844 consolidated without one, inconsistent with ShapeFilterType’s typealias and the transform-matrix methods’ deprecated overloads. Caught by the aggregate review; restored as @available(*, deprecated, renamed: "ShapeType") public typealias TopAbs_ShapeEnum = ShapeType before this PR went any further, so this is additive-again rather than a third breaking change. (#844)

Additive

  • Shape+Modeling’s six legacy fused/subtracted/intersected(tolerance:/glue:) entry points now delegate to union/subtracting/intersection, inheriting the #206 boolean-op timeout watchdog they previously lacked entirely. Each gained a timeout: parameter (default Shape.defaultBooleanTimeout, matching the safer family’s own parameter) so a caller needing longer can opt out of the new bound. (#832)
  • Shape.transformed(matrix:) now takes a new Matrix12Grouped type; transformed(byMatrix:)/ gTransformed(matrix:) now take TransformMatrix3D (previously only a TransformFactory3D return type, now also constructible directly). The two layouts these three methods use (GROUPED vs. INTERLEAVED) were previously indistinguishable [Double] arrays that could be silently swapped with no error; that’s now a compile error. Conversions (Matrix12Grouped.interleaved/TransformMatrix3D.grouped) are provided. The previous [Double]-taking overloads are kept as @available(*, deprecated) forwarders, so existing source still compiles, with a warning. Both new types’ initializers were briefly a trapping precondition on a wrong element count instead of the graceful nil the methods they replace always guaranteed: a caller migrating off the deprecated overload with malformed/deserialized data would crash the process instead of getting nil. Caught by the aggregate review; changed to a failable init?(_:) before this PR went any further. (#835)
  • Shape.VolumeInertia/SurfaceInertia gain hasSymmetryAxis/hasSymmetryPoint fields, matching what InertiaProperties has always reported, read from the same already-computed GProp_PrincipalProps object, no extra cost. (#848)
  • Shape.faceFromPlane/faceFromCylinder’s uBounds/vBounds overload pair gains an additive tolerance: parameter (default 1e-7, matching prior silent behavior); now delegates to the uRange/vRange pair instead of duplicating the underlying BRepLib_MakeFace call. (#841)

Internal only, zero behavior change

  • TopologyRef’s occurrence-bounds guard (duplicated 4x) and ancestor-resolve switch (duplicated 2x) consolidated into two shared private helpers; three previously-untested failure branches gained regression coverage. The three resolver functions still each repeated the same switch/bind/early-return block to use those helpers’ Results, caught by the aggregate review and collapsed to .flatMap chaining, matching the .map style the same functions already used elsewhere. (#846, #854)
  • Face’s six-way-duplicated zero-mass centroid ternary consolidated into one massCentroid helper; isHorizontal is now provably isUpwardFacing || isDownwardFacing computed from a single normal fetch instead of two (a real fix for a review-caught performance regression: the first version of this consolidation fetched normal twice). Face.SurfaceType is now a typealias for Surface.SurfaceType. (#842, #843, #850)
  • ShapeContents/ShapeContentsExtended’s two independent 9-field mappings unified behind one internal ShapeContentsCore type, so the two can no longer silently transpose relative to each other. (#855)
  • WireOrder.analyze(edges:)/analyze(wire:) share one decode helper instead of duplicating the status/index-rebuild logic. (#845)
  • Shape.findSurface/findSurfaceEx/findSurfaceTolerance/findSurfaceExisted (5 entry points) share one internal BRepLib_FindSurface helper; gained a null-shape guard the 3 unguarded ones lacked (not reachable from Swift today). Two review passes caught and fixed three regressions the consolidation introduced along the way: an OCCTSurface allocation briefly sat outside its try/catch (risking a crash instead of a graceful nil on allocation failure), the shared accessor call briefly fate-shared callers that used to be independent (a tolerance-only caller could have failed for reasons that never touched tolerance before), and (caught only once the two fixes above landed and the aggregate diff was reviewed) the fix for the second regression still computed both ToleranceReached() and Existed() on every diagnostic call even though each of findSurfaceTolerance/findSurfaceExisted only ever reads one; a 3-way selector (OCCTFindSurfaceWant) now computes exactly the one each caller needs, matching the pre-consolidation code. All three fixed before this PR went any further. (#838)
  • Shape.orientedBoundingBoxDetailed shares its Bnd_OBB computation with orientedBoundingBox instead of computing a second one. (#847)
  • Shape.recognizeCanonicalSurface/recognizeCanonicalCurve, Shape.pointCloudByTriangulation/ pointCloudByDensity, Shape.coonsFilling/curvedFilling, Shape.discreteTrihedron/ correctedFrenet/draftTrihedron, and GuideTrihedronAC.evaluate/GuideTrihedronPlan.evaluate each share a private helper for their previously-copy-pasted marshaling. No behavioral divergence found in any of the 5 pairs, all pure duplication. (#796, all 5 pairs)
  • Shape.classifyPoint/classify(point:) now route through the same BRepClass3d_SolidClassifier mechanism (previously classifyPoint hand-built the lower-level pieces that class already wraps). (#851)
  • Shape.uniformAbscissa’s four overloads and uniformDeflection’s two overloads share their size-then-fill/malloc-then-unpack idioms; a review pass found and fixed a doubled arc-length computation the first version of the distance: ceiling fix introduced, replacing it with a purpose-built cheap quadrature bridge call. Shape.curveShapeIntersect/ShapeRayIntersection now cross-reference each other in their docs (two independent line/shape intersectors, neither aware of the other). (#852, #853)
  • nurbsConvertViaModifier()/convertedToNURBS() now cross-reference their real divergence (the former skips the latter’s final vertex-tolerance correction pass) instead of two undocumented, unrelated-looking entry points. (#836)

Circle involute with explicit placement (#1023)

Added Geom2dEval.circleInvoluteD0(origin:direction:radius:u:) and Geom2dEval.circleInvoluteD1(origin:direction:radius:u:) to evaluate a circle involute at a parameter with an explicit placement (origin + X direction). Previously the involute was fixed to the canonical coordinate system (origin at 0,0; XDir along +X). The underlying bridge functions OCCTGeom2dEvalCircleInvoluteD0WithPlacement and OCCTGeom2dEvalCircleInvoluteD1WithPlacement were added, along with OCCTGeom2dEvalCircleInvoluteCurveCreate for creating a persistent Geom2dEval_CircleInvoluteCurve.

Self-intersection check with detailed status and cost estimate (#1068)

Added Shape.selfIntersectsDetailed(timeout:facesChecked:totalFacePairs:timeSpent:) returning granular status codes (1 = self-intersects, 0 = clean, -1 = timed out with progress, -2 = timed out no progress, -3 = error). Added Shape.estimateSelfIntersectCost(faces:bsplineFaces:planeFaces:estimatedCost:) for pre-screening check complexity. The original Shape.isSelfIntersecting(timeout:) remains for backward compatibility.

PocketFeature.isOpen stops rebuilding a whole-shape edge map per boundary edge (#777)

AAG.detectPockets(tolerance:)’s enclosure test reached Edge.adjacentFaces(in:) once per floor boundary edge, and OCCTEdgeGetAdjacentFaces rebuilds a whole-shape TopExp::MapShapesAndAncestors edge-to-face map on every call, so the cost of deciding whether one pocket is closed grew with the whole model. It now indexes the covering faces’ own edges once per pocket and tests each boundary edge for membership, which is the same predicate read from the other end and answers with the same identity rule (TopoDS_Shape::IsSame).

Measured (Scripts/repro/777-pocket-isopen/, 25 runs interleaved in one process, medians): 1.1x on a one-edge cylindrical pocket, 5.9x on a 24-sided pocket, 10.6x on a 48-sided one, 18.2x on a plate carrying a 5x5 grid of pockets, and 5.3x on a plate of open slots, which is the one arrangement that favoured the old code, since its contains { !covered } short-circuited on the first uncovered edge. End to end, 18% to 40% comes off the whole detectPocketsAAG() call.

No verdict changes. Over every edge of five fixtures (a box, a cylinder’s seam, a cone’s degenerate apex, a sphere’s poles, and two solids sharing a cut face) the face set the old construction saw is a subset of the new one’s on all 41 edges and never the reverse, which is structural rather than observed: OCCTEdgeGetAdjacentFaces truncates the MapShapesAndAncestors list at two, and that map is the inverse of the per-face MapShapes the new code reads. So the replacement can only ever move a verdict from open to enclosed, and no pocket fixture reachable through detectPockets() does even that.

The route #777 itself proposed, BRepGraph’s indexed edge-to-face incidence, was measured and rejected: building a graph costs more than the whole test it accelerates on five of the seven fixtures, and on the shared-face compound it answers 3 faces where the true occurrence count is 4, because ShapesView::FindNode collapses a face shared between two solids to one node (#642/#699).

bisectorIntersections(a:b:c:d:) no longer discards a crossing past parameter 100 (#1050)

OCCTBisectorInterPointPoint clamped both bisectors’ IntRes2d_Domain to a hardcoded [-100, 100]. A point-point bisector is a half-line trimmed to [0, Precision::Infinite()], so that window spent half its width off the curve entirely and capped the live half at 100, and neither number had any relation to the caller’s four points. A crossing past parameter 100 was silently dropped and the Swift face returned [], indistinguishable from “these bisectors do not meet”.

Each domain is now built from its own bisector’s FirstParameter()/LastParameter(), which is what Bisector_Inter::Perform clips the domain against regardless, so the bridge stops narrowing OCCT’s search rather than narrowing it differently.

// Bisector of (0,0)-(0,10) runs along -x from (0,5); bisector of (-155,0)-(-145,0)
// runs along +y from (-150,0). They meet at (-150, 5), at parameter 150.
let hits = bisectorIntersections(a: (0, 0), b: (0, 10), c: (-155, 0), d: (-145, 0))
// before: []            after: [(x: -150, y: 5, paramOnFirst: 150, ...)]

Measured over 16000 randomised four-point configurations at four scales: 1953 crossings gained, 0 lost, 0 moved, 0 bogus, with every gained crossing checked to be equidistant from both pairs and on the live side of both rays (worst relative error 3.69e-15). At scale 1 the old window dropped 4 in 4000; at scale 1e3 it dropped 980, which is why it looked adequate. lost and moved can only fire where the old window found a crossing at all, so those two zeros rest on 2005 opportunities rather than on all 16000.

Two bounds that look like the fix are not, and both were measured rather than argued (Scripts/repro/1050-bisector-domain/). An unbounded IntRes2d_Domain() raises Standard_DomainError inside Bisector_Inter::Perform, which the bridge’s own catch (...) turns back into the same empty result. A bound derived from the input points’ extent (2 * span + 1) passes the issue’s own fixtures and still drops a crossing at a 10.9 degree angle, because a crossing sits about d / sin(angle) from a midpoint and nothing bounds sin(angle) below.

docs/reference/Shape-Recognition.md now documents the search range and where it ends, the half-line semantics (of the four orderings of its own example, one returns the circumcentre and three return []), the four causes of an empty result with the measured threshold for the first, and why a distant crossing is accurate but ill-conditioned in the input. It also corrects the OCCT attribution from Bisector_BisecCC, which this entry point never reaches.

SAWireAnalysis.checkOuterBound returns Bool?, so a refused check is not the same answer as a clean one (#1058)

checkOuterBound(wire:face:) answered false both for a wire that is the face’s outer bound and from every path that could not run the check, with no second channel to tell them apart. It now returns Bool?:

switch SAWireAnalysis.checkOuterBound(wire: wire, face: panel) {
case true?:  print("not the outer bound")
case false?: print("the outer bound")
case nil:    print("not checkable against this face")
}

nil covers four inputs:

  • a Shape that is not a wire or not a face, reachable because the signature takes two plain Shape values with no type constraint;
  • a wire with no edges, which ShapeAnalysis_Wire::IsReady() rejects;
  • a wire whose edges do not assemble, where ShapeExtend_WireData::WireAPIMake() returns a null wire and BRep_Builder::Add dereferences it with no null test. That one was an uncatchable SIGSEGV rather than a wrong answer, and CheckOuterBound builds the same wire, so it crashed before this change too;
  • a wire with no pcurve on the face, which OCCT does not reject: ShapeAnalysis::TotCross2D skips every edge whose pcurve on the face is null, so with none left its accumulator is never written and the +0.0 it starts from signs as a positive area, reporting a foreign wire as the outer bound. This one needs a non-planar support face to observe, because BRep_Tool::CurveOnSurface projects a 3D curve onto a plane when no pcurve is stored.

All four measured in Scripts/repro/1058-outer-bound-refusal/.

The ten SAWireAnalysis siblings still return Bool. They share the first two refusal paths and not the third, measured on the same fixtures, and each needs its own adjudication of what nil would mean for it.

Amendment for the merger, in the same transcription commit

docs/CHANGELOG.md’s #999 entry, in the same ## Unreleased section, currently ends its checkOuterBound paragraph with:

It now calls ShapeAnalysis_Wire::CheckOuterBound and returns true when a problem is found, matching every sibling. There is no precision, because that check consults none.

The first sentence stops being true when this merges, and both entries ship in one set of release notes. Replace it with:

It now calls ShapeAnalysis_Wire::CheckOuterBound. There is no precision, because that check consults none.

That is all: the return contract is the next entry’s subject, so the #999 entry should stop describing it rather than describe it twice. docs/CHANGELOG.md is deliberately not in this PR’s diff, per changelog-on-merge; this block is finished text for the merger to apply, not a draft.

isSelfIntersecting(timeout:) no longer reports a timed-out or rejected check as a self-intersection (#1054)

Shape.isSelfIntersecting(timeout:) and Shape.isSelfIntersecting(hardTimeout:) decided from BOPAlgo_ArgumentAnalyzer::HasFaulty(), which is the union over every mode the analyzer was asked to run, read before the watchdog. Every kind of fault that is not a self-intersection therefore came back as true:

  • An aborted analysis. BOPAlgo_CheckerSI::CheckFaceSelfIntersection clears BOPDS_DS::Interferences() on entry, and the PostTreat that follows re-adds only pairs passing its own per-type gates, which for a valid solid’s face adjacency is none. An analysis stopped before that Clear() is read against the pave filler’s own raw map instead. Measured by breaking a plain 10x10x10 box at each of its progress polls in turn: 69 of 401 break points answered “self-intersects” for a clean box, against zero faults for the uninterrupted run, and the transition sits at a single poll with every other observable identical either side.
  • An argument the analyzer rejects. ArgumentTypeMode is enabled for basic sanity, and it records BOPAlgo_BadType on either of two TestTypes branches: a shape with no geometry anywhere below it (emptied’s result), and a null shape (nullified’s), which is caught earlier and without BOPTools_AlgoTools3D::IsEmptyShape being consulted at all. Both were true. Shape.compound([]) is not a third way in, OCCTShapeCreateCompound refuses an empty array. No timeout is involved in either; this was true on a stock kernel.
  • An analysis that failed. BOPAlgo_CheckUnknown, and a BOPAlgo_OperationAborted recorded for a BOPAlgo_CheckerSI error that was not a watchdog break, which is why passing timeout: 0 does not exempt a caller from any of this.

All of them are now nil, the value the API already documents as “indeterminate, treat as unknown, not clean”. A completed analysis that finds an interference still returns true.

#1054’s own report says “Not proven: I did not directly observe the spurious 1”. It is directly observed now: the #319 pathological artifact at a 30 s bound records exactly one BOPAlgo_OperationAborted, with no BOPAlgo_SelfIntersect anywhere in the list, on 6 of 6 runs on an idle machine. That is also Scripts/repro/772-analyze-self-intersection/’s row 4, whose “conclusive self-intersects around 30.05-30.15s” was this abort, so Shape.analyze’s “Why timeout:, not hardTimeout:” rationale loses one of its four arguments. The rationale and the choice both stand on the other three; the withdrawal is recorded in Shape+Analysis.swift, in docs/reference/Shape-Features.md, in the #772 repro’s own README and in the #772 test suite’s doc comment, rather than quietly deleted. One consequence for a caller: analyze(tolerance:selfIntersectionTimeout:) on a shape whose check times out now leaves hasSelfIntersection nil instead of true, which is the same change as above seen through analyze.

One behaviour change beyond the bug. A genuinely self-intersecting shape whose check times out now returns nil where it used to return true. That is deliberate: the box measurement above shows a BOPAlgo_SelfIntersect result from an aborted run cannot be told from an artefact of stopping early. Callers that want the old behaviour should raise timeout until the check completes; a caller treating nil as “clean” was already outside the documented contract.

Reproducers, transcripts and the #1068 measurement are in Scripts/repro/1054-selfintersect-fault-kinds/.

README and a new guide say what a consumer’s own target has to set, and why an Objective-C file including an OCCT header fails (#967)

An external user reported that v3.0.0 would not compile in their project, with 'type_traits' file not found at Standard_Std.hxx’s #include <type_traits>. Reproduced from the outside, on both SwiftPM and Xcode: OCCT.xcframework ships roughly 7,000 C++ headers and declares a HeadersPath, so both build systems put them on the include path of anything that links the package. #include <STEPControl_Reader.hxx> therefore resolves from a file in the consumer’s own target, and if that file is Objective-C (.m) or C rather than Objective-C++ (.mm) or C++, the C++ standard library is not on its include path and Standard_Std.hxx:19 stops the build. Renaming it .mm is the fix, provided the target is at C++17 or later; below that the build clears <type_traits> and then fails on std::is_trivially_copyable_v in NCollection_LinearVector.hxx, which is a separate wall and easy to mistake for the same one.

Nothing in the package changes. Ten Swift-only consumer shapes were built across SwiftPM and Xcode, on macOS, iOS device and iOS simulator, with and without .interoperabilityMode(.Cxx) on the consumer’s target, and every one is green, so a Swift consumer needs no cxxLanguageStandard, no C++ interop mode and no extra build settings. README.md now says that in a short “What your own target has to set” section, the detail lives in the new docs/guides/consuming-from-objective-c.md, and Scripts/repro/967-consumer-compile/ carries the full grid, a run.sh that rebuilds nine consumer shapes and checks each against its expected outcome, and captured output for every remaining row.

Three unrelated findings from the same sweep, all in files this change already touches. There is no Mac Catalyst slice in the xcframework, so that destination fails at build planning with no library for this platform was found in OCCT.xcframework, and unlike visionOS and tvOS there is no local-rebuild route because Scripts/build-occt.sh has no Catalyst target; README’s platform table now has a row for it, where it previously said nothing at all. README’s install snippet said from: "1.0.0", which resolves to the 1.x line and never reaches 3.0.0. And docs/index.md said 4,339 wrapped operations against a derived 4,355, because count-operations.py read only README and docs/API_REFERENCE.md; it now reads docs/index.md too, so the figure that drifted is no longer the one nothing was watching.

Datum.name no longer truncates, and two GD&T write paths no longer report success for a request the document did not take (#1055, #1056)

Datum.name came back cut at 63 characters while createDatum(name:) stored the whole string, with nothing in the chain reporting the loss. The bridge’s OCCTDatumInfo.name[64] is replaced by OCCTDocumentGetDatumName, which fills a buffer the caller sizes and returns the length of the whole identifier, so a C caller can size exactly and truncation is never silent. Datum.name now reads back whatever was written, at any length.

createDimension(on:type:value:lowerTolerance:upperTolerance:) discarded the tolerance setter’s result, so a pair the document refused still returned an index for a dimension whose tolerance had been dropped:

// before: idx == 0, and doc.dimension(at: 0)?.bounds == .simple
// now:    nil, and nothing is created
let idx = doc.createDimension(on: label, type: .sizeDiameter, value: 20.0,
                              lowerTolerance: .nan, upperTolerance: 0.5)

setGeomToleranceZoneModifier(at:_:value:) stored the value even under .none, so geomTolerance(at:) reported a projected-zone length on a tolerance with no projected zone. Clearing the modifier now clears the value with it, matching setDatumModifierWithValue(at:_:value:).

Six further bridge string returns share the fixed-buffer shape and are filed as #1078, not changed here.

Features lane audited against the pinned refman, in both directions (#811)

Pass 4a of #807. Ten OCCT packages, 129 classes, compared against occt-refman@8.0.1 and the pinned headers.

Under-coverage. 47 of the 129 were neither wrapped nor documented and none carried a recorded reason. docs/occtswift-wrapping-gaps.md gains a features-lane section covering all 47, grouped by measured reason: 24 collection aliases deprecated at file scope since OCCT 8.0.0, five abstract bases, four enums, one typedef covered by its sibling, one header that declares no class, seven internal helpers, and five classes needing a real decision, of which four are genuine gaps. Plate_SampledCurveConstraint is the one Plate_Plate::Load overload of nine that no bridge function reaches by any route.

Over-coverage. 42 findings, corrected here except the eight #1069 fixed independently while this branch was in review. The largest groups: four docs/reference/Surface-Advanced.md entries naming the “(no G0)” NLPlate_HPGnConstraint classes where the bridge builds the HPG0Gn form; five BRepFilletAPI_MakeChamfer/MakeFillet members that do not exist in OCCT 8.0.1 (IsDistAngle for IsDistanceAngle, GetDists for Dists, IsSymmetric for IsSymetric, IsTwoDists for IsTwoDistances, NbSimulatedSurf for NbSurf), each spelled from the bridge function’s own name rather than read from the header; four BRepOffsetAPI_NormalProjection attributions where OCCTShapeProjectWire runs BRepProj_Projection; and Shape.withPrism/withBoss/withPocket attributed to BRepFeat_MakePrism where the bridge runs BRepPrimAPI_MakePrism plus a boolean.

Artifact. Scripts/repro/811-refman-coverage-features/: the by-call lane derivation, the census with a self-test and a --verify-pins mode that asserts every pinned correction was real at the base revision, a removal matrix that proves each detector shape load-bearing, and a probe.

Filed, not fixed here: #1044 (65 method-attribution findings outside this lane), #1045 (fifteen packages below the lane, 337 headers, that belong to no pass), #1046 and #1049 (two NLPlate behaviour defects, both since fixed by #1069), #1047 (Shape.withPrism is named after a feature prism it does not run). #1021’s seven class rows are adjudicated in comments there.

A nullified shape refuses the edge, face and vertex accessors instead of crashing (#1035)

Twelve public APIs took the process down with an uncatchable SIGSEGV when handed a Shape from Shape.nullified: extractEdgeCurve3D(), edgeCurveWithParams(), extractFaceSurface(), faceSurfaceGeom(), edgeTolerance, faceTolerance, vertexTolerance, extractEdgePCurve(onFace:), isEdgeDegenerated, the two infinite extrusions, and ShapeFixer. Each now returns the refusal it already gave a wrong-typed input (nil, 0 or false); none invents a value.

TopoDS::Edge and its siblings are written theShape.IsNull() ? false : ..., so a null shape is deliberately not a type mismatch: the cast returns a null TopoDS_Edge and the crash happens one frame further out, at BRep_Tool::Curve, a BRepAdaptor_Curve constructor or ShapeFix_Shape::Perform. Scripts/repro/1035-unwrap-guard/repro_1035.mm measures 63 OCCT entry points the bridge hands a caller-supplied shape to: 17 crash uncatchably, 5 raise a catchable Standard_Failure, 41 return. 72 bridge sites reaching one of the 17 are guarded, and check-null-handle-guards.py now holds them, walking outward through the transparent casts so a new site of the same shape turns the check red.

ShapeFixer(shape:) had no null test of any kind. Its declared return is _Nonnull, so its refusal is an empty handle: perform() returns false and shape returns nil for a null input.

#1035’s own proposal, a throwing occtShapeIn accessor replacing all 1405 unwraps, is declined and the reasoning is recorded in Scripts/repro/1035-unwrap-guard/README.md: at thirteen sites a null shape is the function’s legitimate subject and today’s answer is correct, so a uniform throw would be a regression there.

None. No shipped API, behaviour or output changes; the only string literals touched are display text no test reads.

Shape.isEmptyShape is renamed to isNull, and Shape.nullified is deprecated (#1034)

isEmptyShape was TopoDS_Shape::IsNull() under a name that reads as “has no sub-shapes”. Those are different questions, and Shape.emptied answers yes to the second while isEmptyShape answered no: an emptied box has zero faces and is not null. The predicate is now Shape.isNull, with isEmptyShape kept as a deprecated alias.

Shape.nullified is deprecated in favour of Shape.emptied. Nullify() clears the type as well as the content, and the result answers no type query meaningfully; emptied keeps the type and drops the sub-shapes, which is what callers of “give me an empty copy” want.

let box = Shape.box(width: 10, height: 10, depth: 10)!
print(box.emptied!.isNull)    // false, it is still a Solid with no faces
print(box.nullified!.isNull)  // true

GeomPlate_BuildPlateSurface and XCAFDoc_Datum kernel patches carried (#1018, #1022)

Two upstream OCCT defects are now carried as Scripts/patches/0028 and 0029, and filed upstream as OCCT#1481 and OCCT#1483. Neither is in the pinned kernel asset, so Scripts/patches/ holds nineteen against the asset’s seventeen until a rebuild; Package.swift and CLAUDE.md name both and say what each costs.

0028 (#1018): GeomPlate_BuildPlateSurface::G0Error()/G1Error()/G2Error() return uninitialised members after a Perform() whose constraints were all point constraints, because VerifSurface() is their only writer and the point-only branch measures the same deviations and discards them. The patch initialises the three, clears them when Perform() starts, keeps what the point-only branch measured, and makes VerifPoints() accumulate a maximum rather than overwrite, matching what the accessors document. This is the defect that made #1015 delete Surface.plateErrors rather than repair it; nothing in the Swift API reads those accessors today.

0029 (#1022): XCAFDoc_Datum::GetObject builds the datum point’s X from the annotation plane’s array instead of the point’s own, a wrong answer when a datum has both and an uncatchable SIGSEGV when it has a point and no plane. Seven bridge functions reach it, five of them write paths, for any OCAF document whose datum carries a point without an annotation plane. A STEP import cannot produce that shape; an OCAF load can. A bridge-side guard, needed until a rebuilt kernel ships, is tracked as #1030.

docs/API_REFERENCE.md drops Surface.plateErrors from the Surfaces row, deleted by #999 (PR #1015) and missed there because the gate does not read bare table text.

.perspective(focus:) refuses a shape at or beyond the eye, and the docs name the real anchor (#1036)

Drawing.project(_:direction:type:) used to return a real, mirrored Drawing for a shape sitting past the perspective eye point, and the documentation described an anchor and a threshold that were both wrong.

OCCTDrawingCreate builds its projection frame as gp_Ax2(gp_Pnt(0, 0, 0), viewDir) unconditionally, so the eye is at focus * direction measured from the world origin, the picture plane passes through the origin, and HLRAlgo_Projector::Project divides by R = 1 - Z/focus with Z in that frame. Any point with Z >= focus therefore has R <= 0 and was drawn mirrored through the origin instead of not at all. Measured on a 10-unit cube spanning x [20, 30], z [1000, 1010] viewed down +Z: focus 50 returned seven edges spanning x [-1.579, -1.042] where the correct answer is [40, 60.606]. Two further regimes behaved the same way: an eye plane cutting the shape returned one half mirrored against the other (x [-24.444, 20]), and an eye landing exactly on a face returned coordinates of order 1e17. All three reported success with a plausible edge count.

.perspective now returns nil whenever the shape reaches the eye plane, matching the refusal the non-positive-focus guard beside it already made for the same stated reason. Correct regimes are unchanged, including the extreme ones: a focal distance just clear of the shape still projects, and so does a shape far behind the picture plane.

let far = Shape.box(origin: SIMD3(-5, -5, 1000), width: 10, height: 10, depth: 10)!

// Was a mirrored, 19x under-scaled Drawing. Now nil.
Drawing.project(far, direction: SIMD3(0, 0, 1), type: .perspective(focus: 50))

// The eye has to clear the shape's reach from the ORIGIN, not from the shape.
Drawing.project(far, direction: SIMD3(0, 0, 1), type: .perspective(focus: 2000))

Drawing.ProjectionType.perspective(focus:) and docs/reference/Drawing.md now state where the eye actually sits, that the drawing’s scale follows the shape’s world position while its foreshortening ratio does not, and that translating the shape to the origin is how to make the scale independent of where the part was modelled.

Five GD&T setters reject values that name no enumerator (#1037)

OCCTDocumentSetDimensionClassOfTolerance (two casts), OCCTDocumentSetDimensionModifiers, OCCTDocumentSetGeomToleranceModifiers and OCCTDocumentSetDatumModifiers cast a caller-supplied int32_t straight into an OCCT GD&T enum, so OCCTDocumentSetDimensionModifiers(doc, 0, (int32_t[]){9999}, 1) wrote 9999 into the document and read it back. An out-of-range formVariance was worse than storage: it also made IsDimWithClassOfTolerance() true, since that predicate is a bare != _None test, so the reader reported hasClassOfTolerance == true alongside classOfTolerance == nil.

All five now range-check, matching the six neighbouring setters added in the same commits that already did. For the three array setters the whole array is validated before any of it is stored, so a rejected call leaves the previous sequence intact rather than half-written. Not reachable from Swift, whose GD&T enums are complete and typed; this closes the C boundary.

setDatumTargetPlacement refuses a call that would persist nothing (#1038)

XCAFDoc_Datum::SetObject nests its whole axis/length/width/number block inside if (theObject->IsDatumTarget()), and inside that takes an Area branch that stores the target’s own shape rather than a placement. So calling setDatumTargetPlacement(at:location:normal:reference:length:width:) on a datum that has had no setDatumTarget(at:type:number:) applied, or whose target type is .area, set every value on the in-memory object and then discarded all of them, while returning true.

Both cases now return false. The precondition is recorded on the bridge declaration, the Swift doc comment and docs/reference/Annotation.md, and the Swift doc’s example now shows the required ordering explicitly.

doc.setDatumTarget(at: idx, type: .rectangle, number: 1)   // required first
doc.setDatumTargetPlacement(
    at: idx, location: SIMD3(1, 2, 3), normal: SIMD3(0, 0, 1), reference: SIMD3(1, 0, 0),
    length: 30, width: 18)

A null TopoDS_Shape no longer crashes 46 bridge entry points (#1026)

Shape.nullified returns a Shape wrapping a null TopoDS_Shape, and forty-six bridge functions hand a caller-supplied shape to something that dereferences it without a null test: TopoDS_Shape::ShapeType(), its eight flag accessors, four TopoDS_Builder::Add/Remove sites, and three that hand the shape to an OCCT constructor which dereferences it. TopoDS_TShape::ShapeType() is a plain member read, so each was a SIGSEGV at address 0x38 that no bridge-side catch (...) could absorb, reachable from public API alone: box.nullified!.shapeType, .isSolid, .typeName, .shapeTypeString, .isValidSolid, .isFree, .isClosedShape, Shape.healed(), Shape.compound([shape.nullified!]), LengthDimension(edge:) and more.

Every one now answers instead of crashing, with the refusal that function already gave a wrong-typed or null-pointer input: .unknown for shapeType, nil for typeName, "null" for shapeTypeString, false for every predicate and flag, 0 intersections, nil for every Shape-returning operation. No signature changed. Shape.isEmptyShape is what separates a null shape from a real negative, and it already existed.

check-null-handle-guards.py grows a third walk for this class, tied to the specific argument, covering the ten TopoDS_Shape members that dereference myTShape unguarded plus a shape handed to a TopoDS_Builder, which found twenty-three of the forty-six on its own. The census the issue was filed from (Scripts/repro/1008-topods-cast-guard/shapetype_census.py) accepted an IsNull() on any subject as a guard and reported fifteen; it is corrected and given a --self-test. See Scripts/repro/1026-null-shape-type-guard/ for the faulting address, the flag-accessor measurements and the guard-removal matrix.

setDimensionBounds refuses a plus/minus dimension instead of corrupting it (#996)

Document.setDimensionBounds(at:lower:upper:) returned true on a dimension that already carried plus/minus tolerances, while leaving it in a state matching neither the request nor the original: the requested upper bound was stored as the lower tolerance, the original upper tolerance survived, and bounds stayed .plusMinus rather than becoming the documented .range. It now returns false and leaves the dimension untouched. Converting a dimension that has no tolerances is unchanged.

Shape.wireFromEdges and Shape.shellFromFaces no longer crash on a null or wrong-typed shape (#1008)

Both entry points cast every element they are handed with TopoDS::Edge / TopoDS::Face and passed the result to a builder with no type test. TopoDS::Edge’s own guard passes a null TopoDS_Shape through deliberately (theShape.IsNull() ? false : ...), and TopoDS_Shape::ShapeType() dereferences its handle without a null test, so the builder crashed with a SIGSEGV that no catch (...) on the bridge side could absorb. Shape.nullified returns exactly such a shape, so Shape.wireFromEdges([shape.nullified!]) was an uncatchable crash reachable from public API alone. Both now return nil for a null element, and for an element that is not an edge or a face respectively.

A wrong-typed element was never the silent reinterpret_cast the issue described: two independent kernel guards refuse it, one of them (TopoDS_Builder::Add’s compatibility table) an unconditional throw rather than a macro, and No_Exception is defined only inside OCCT’s own Release translation units, never in a bridge one. See Scripts/repro/1008-topods-cast-guard/ for both compile modes’ transcripts and the 345-site sibling sweep.

One shared GROUPED 12-double matrix reader, and a corrected reflection contract on Document.addComponent(matrix:) (#1009)

OCCTCurve3DParametricTransformation, OCCTDocumentAddComponentMatrix and OCCTShapeTransformed each carried their own byte-identical copy of the permuted gp_Trsf::SetValues that reads Matrix12Grouped’s GROUPED layout. All three now share occtTrsfFromMatrix12Grouped in OCCTBridge_Internal.h, alongside occtTrsfFromMatrix12Interleaved. The two layouts deliberately do not share a reader: a GROUPED array read as INTERLEAVED yields translation (0, 0, 7) where (5, 6, 7) was meant, and is accepted silently.

Document.addComponent(assemblyLabelId:shapeLabelId:matrix:) does accept a reflection and apply it. Its doc comment and docs/reference/Document-Persistence-IO.md said the opposite, on the strength of #174’s assumption that gp_Trsf::SetValues rejects a non-rigid transform. Measured against the pinned kernel it rejects nothing at all: its only documented precondition is a null determinant, not orthonormality, and it is compiled inside OCCT’s Release library where even that is removed. A box spanning x ∈ [1, 11] placed by a mirror in X spans [-11, -1]. No behaviour changed here, only the documentation of behaviour that was already there. See Scripts/repro/1009-matrix12-grouped/.

GD&T: a tolerance’s zone semantics and a datum’s frame position and target are now readable (#1004, #1021)

Document.GeomTolerance gains valueType, materialRequirement, zoneModifier, zoneModifierValue, maxValueModifier and modifiers. Document.Datum gains position, modifiers, modifierWithValue and target, the last a nested Datum.Target carrying the target’s type, number, length and width. Each read accessor ships with the mutator that authors it: setGeomToleranceValueType(at:_:), setGeomToleranceMaterialRequirement(at:_:), setGeomToleranceZoneModifier(at:_:value:), setGeomToleranceMaxValueModifier(at:_:), setGeomToleranceModifiers(at:_:), setDatumPosition(at:_:), setDatumModifiers(at:_:), setDatumModifierWithValue(at:_:value:), setDatumTarget(at:type:number:), clearDatumTarget(at:) and setDatumTargetPlacement(at:location:normal:reference:length:width:).

Seven new enums join Scripts/derive-gdt-enums.py’s gate, which now covers 14 enums and 188 members and leaves exactly one XCAFDimTolObjects enum unbound.

Absence is representable throughout, from measurement rather than convention. Datum.position is nil for a datum with no place in a reference frame, because positions are 1-based. zoneModifierValue and maxValueModifier are nil rather than 0.0, because OCCT stores each only when positive and an unstored one reads back as zero. A datum target’s length and width follow the target type: .point keeps neither, .rectangle keeps both, .line and .circle keep the length, and .area keeps neither because OCCT stores its shape instead of a placement.

setDatumTargetPlacement writes the axis, length and width in one call because each of OCCT’s three setters raises the same presence flag, so writing one alone would report the other two as present while leaving them unassigned. OCCTDocumentCreateGeomTolerance and OCCTDocumentCreateDatum now assign the members SetObject reads unconditionally, for the same reason OCCTDocumentCreateDimension did in the previous entry.

docs/occtswift-wrapping-gaps.md records the accessors deliberately left unwrapped on both classes, and adjudicates XCAFDoc_DimTolTool’s own coverage for #1021: the reverse lookups and the datum-to-tolerance association are a real gap and the largest one remaining in this surface, while the legacy XCAFDoc_DimTol API, the label classifiers, the editing lock and the plumbing are deliberate omissions.

Plate and NLPlate parameters now mean what they are named (#1017, #1019, #1020)

Surface.nlPlateDeformed(constraints:maxIterations:tolerance:) and nlPlateDeformedG1(constraints:maxIterations:tolerance:) are renamed to resolutionOrder:. The parameter was never an iteration count: NLPlate_NLPlate::Solve2(ord, InitialConsraintOrder) has none, and the value landed on the plate’s resolution order, which Plate_Plate::SolveTI accepts only in [2, 9]. Outside that range Solve2 reported success anyway and the call returned an undeformed surface: measured, a constraint 5 units off the plane was missed by the full 5 at orders 0, 1, 10, 12 and 100. Those orders now return nil. The default stays at 4, which meets the same constraint to 6.3e-6, where OCCT’s own default of 2 misses it by 4.375.

NLPlateDeformationTests is re-enabled. It had been disabled since v0.51-era for a segfault that does not reproduce against the pinned kernel: 7 tests, 20 consecutive clean runs.

Edge.split(at:vertex:) now refuses a parameter outside the edge’s own range. It previously extrapolated: on a line trimmed to [-5, 5], splitting at 100 returned halves of length 105 and 95 against an original length of 10.

ExtremaPointCurve.pointToLine and .pointToParabola no longer clip the search to a fabricated parameter range (±1e10 and ±1e6 respectively, four orders of magnitude apart in sibling calls). Both curves are unbounded and Extrema_ExtPElC uses the range only to filter an answer it has already computed, so a point projecting past the old bound returned no extremum at all. Both now use the full representable range, matching OCCT’s own unbounded-conic call sites.

Shape.plateSurface(points:) stops passing its 3D tolerance into GeomPlate_BuildPlateSurface’s 2D Tol2d slot. No behaviour change: neither builder tolerance is read for a point-constraint-only plate, measured across fourteen orders of magnitude in both slots on two fixtures.

GD&T: a dimension’s qualifiers, modifiers and decimal places are now readable (#1004)

Document.Dimension gains qualifier, angularQualifier, decimalPlaces and modifiers, and Document.DimensionType gains isDimensionalLocation and isDimensionalSize. Each read accessor ships with the mutator that authors it: setDimensionQualifier(at:_:), setDimensionAngularQualifier(at:_:), setDimensionDecimalPlaces(at:left:right:) and setDimensionModifiers(at:_:).

Three new enums, Document.DimensionQualifier, Document.AngularQualifier and Document.DimensionModifier, are transcribed from the pinned kernel’s own headers and join Scripts/derive-gdt-enums.py’s gate, which now covers 7 enums and 129 members.

Absence is representable everywhere it needed to be. decimalPlaces is nil rather than a fabricated (0, 0), gated on the same theL > 0 || theR > 0 condition OCCT itself stores the pair under. The two qualifiers are not optional, because OCCT’s own enums carry a _None member and wrapping that in an Optional would give two spellings of one state.

OCCTDocumentCreateDimension now assigns the qualifier, angular qualifier, decimal places and tolerance class explicitly. XCAFDimTolObjects_DimensionObject’s constructor leaves all four unassigned and XCAFDoc_Dimension::SetObject reads them unconditionally, so the create path was handing OCCT storage nobody wrote. Measured against the pinned kernel it reads back as zero, so no behaviour changes today.

Twenty-six accessors are deliberately not wrapped, each with its reason in docs/occtswift-wrapping-gaps.md. GetSemanticName is the one worth knowing: it reads the GD&T table label’s own TDataStd_Name, which OCCT initialises to "DGT:Dimension", so an unnamed dimension reports that marker string and a name cannot be cleared once set.

One 2D-to-3D lifting formula instead of three (#972)

Placement.lift(_:) is the single implementation of “map a 2D point through this placement’s basis”. It replaces Sketch’s private lift(_:with:), SheetMetal.Flange.worldPoint(_:) and an inline map in FeatureReconstructor.applyExtrude, all three of which computed origin + x * xAxis + y * yAxis independently.

let plane = Placement(origin: SIMD3(1, 2, 3), normal: SIMD3(0, 0, 1))
let corner = plane.lift(SIMD2(4, 5))

No behaviour change to any of the three call sites. Flange gains an internal placement, built once in its initialiser rather than per point, and deliberately not public: Placement documents an orthonormal basis, and Flange lets a caller supply any uAxis/vAxis by its own documented design, so the frame it carries is not one a caller may treat as unit.

DraftInfo is removed (#1000)

DraftInfo’s six members never read the caller’s input. Five constructed a default Draft_EdgeInfo / Draft_FaceInfo / Draft_VertexInfo, read one property off that throwaway and returned it, so edgeInfoNewGeometry, faceInfoNewGeometry and vertexInfoGeometry were constants; edgeInfoSetTangent(direction:) ignored the direction and returned a value that was trivially true; faceInfoFromSurface(_:) performed no RootFace check, only reporting whether the constructor threw; and vertexInfoAddParameter(_:) echoed its argument.

There is no replacement, and none is possible through OCCT’s public API: Draft_Modification is the only class that populates Draft_*Info objects, and it holds them in private members with no accessor. Real draft introspection would need an upstream kernel change, not a wrapper fix. Delete any call; every one of the six had a fixed answer.

Drawing.project(_:direction:type:) honours .perspective (#999)

Drawing.ProjectionType.perspective was accepted and discarded: OCCTDrawingCreate declared the projection type and never read it, so every perspective request returned the orthographic projection with no error. It now builds HLRAlgo_Projector(gp_Ax2, Focus), and ProjectionType carries the focal distance the perspective constructor requires:

let box = Shape.box(width: 100, height: 50, depth: 30)!
// The near face is 15 units towards the eye, so it projects 50/(50-15) = 1.4286x larger.
let near = Drawing.project(box, direction: SIMD3(0, 0, 1), type: .perspective(focus: 50))

ProjectionType is no longer UInt32-backed, and .perspective now takes a focus:. A focal distance that is not strictly positive returns nil rather than a projection: OCCT raises nothing for one, returning either an empty result (zero) or a differently scaled projection (negative).

Giving .perspective an associated value costs more than the raw value, and it is worth naming each piece, because the mechanism is not the one it looks like. Measured with swiftc rather than recalled: a payload-free enum is Equatable and Hashable whether or not it says so, in its own module and across a boundary, and one with an associated value is neither until it declares them. Sendable follows a different rule again, on publicness rather than on the payload. So the raw type was only ever supplying RawRepresentable; the payload is what removed the other two, and the replacement declaration named Sendable, Equatable.

Conformance What this change did to it
RawRepresentable, so .rawValue and init(rawValue:) Removed, permanently. A Swift enum cannot carry both a raw type and an associated value
Equatable Removed with the payload and re-declared, so == still exists. A comparison against the bare case (type == .perspective) still stops compiling, because that case now takes an argument
Hashable, so Set<ProjectionType> and [ProjectionType: T] Removed with the payload and not re-declared here. Restored before release, in its own entry in this section
Sendable Added. A public enum is not implicitly Sendable, whatever its cases carry, so the UInt32-backed form did not have it either, in its own module or outside it

A caller who was round-tripping through .rawValue switches over the cases instead, see docs/reference/Drawing.md.

Drawing.projectFast(_:direction:deflection:) is unchanged and remains orthographic. It never exposed a projection type, and HLRBRep_PolyAlgo ignores a projector’s perspective flag entirely, so there was nothing to expose.

One quilt path and one first-edge answer inside the bridge (#974, #975)

OCCTShapeQuilt and OCCTShapeQuiltWithHistory now share one BRepTools_Quilt feeding helper instead of two byte-identical copies, and the four entry points that open-coded “the first edge of this shape” (OCCTWireMakeWireFromEdges, OCCTChFi2dFilletAlgo, OCCTChFi2dAnaFillet, OCCTBRepExtremaExtCCEdges, seven copies across two files) now call occtEdgeAt(shape, 0), the shared enumeration OCCTBRepExtremaExtCC was converted to by #613. No behaviour change: the two edge spellings were measured equivalent over eleven fixtures first, same edge and same orientation on every one (Scripts/repro/975-first-edge-idiom/). Shape.quilt, Shape.quiltWithFullHistory, Shape.anaFillet and Shape.filletAlgo gain their first assertions on what they actually build.

One INTERLEAVED matrix-to-transform conversion inside the bridge (#994)

OCCTBridge_Topology.mm and OCCTBridge_BRepGraph.mm each carried their own conversion between a 12-double matrix and a gp_Trsf. Both now use occtTrsfFromMatrix12Interleaved, occtMatrix12InterleavedFromTrsf and occtLocationFromMatrix12Interleaved in OCCTBridge_Internal.h, whose names carry the layout because the bridge’s other 12-double convention (GROUPED, Matrix12Grouped, #835) is silently accepted rather than refused when the two are confused. No behaviour change: the two spellings were measured to agree bit for bit over five matrices first, with an exact round trip (Scripts/repro/994-matrix12-interleaved/). Shape.located(matrix:), Shape.locationMatrix and Shape.setLocation(matrix:) gain their first tests.

One discriminated gp_Trsf builder for the 3D transform families (#995)

OCCTBridge_Curve3D.mm and OCCTBridge_Surface.mm each carried a byte-identical buildTrsf3D, the discriminated builder behind both files’ in-place transform dispatcher and their six immutable translate/rotate/scale/mirror entry points. Both now call occtBuildTrsf3D in OCCTBridge_Internal.h. No behaviour change and no public API change: the type codes are unchanged, and the six transforms are now pinned by tests that map one probe point through the curve and surface families, immutable and in place, against values computed independently.

One GD&T read family, and OCCT’s dimension kinds instead of zero-filled fields (#996)

Document’s GD&T read surface existed twice: an untyped family (DimensionInfo, GeomToleranceInfo, DatumInfo, reached through dimension(at:) and friends) and a typed family (Document.Dimension, GeomTolerance, Datum, reached through typedDimension(at:) and friends), both reading the same three bridge calls. There is now one: the typed value types under the untyped family’s method names. DimensionInfo, GeomToleranceInfo, DatumInfo, typedDimension(at:), typedGeomTolerance(at:), typedDatum(at:), typedDimensions, typedGeomTolerances and typedDatums are removed.

A range dimension used to read back as a different dimension. XCAFDimTolObjects_DimensionObject encodes a dimension’s magnitude in a values array whose length is the discriminator, and the bridge read the first slot and called it value. For a range that is the lower bound, and both tolerance accessors answer 0, so a 10..12 range came back as value = 10, lowerTolerance = 0, upperTolerance = 0, which is exactly what a plain 10mm dimension with zero tolerance comes back as. Document.Dimension.bounds mirrors OCCT’s own predicates instead:

if let dim = doc.dimension(at: 0) {
    switch dim.bounds {
    case .range(let lower, let upper): print("ranges \(lower) to \(upper)")
    case .plusMinus(let lo, let hi): print("\(dim.value ?? 0) \(lo)/\(hi)")
    case .simple: print("\(dim.value ?? 0)")
    case .unset: print("no value")
    }
}

value is now Double? and classOfTolerance is optional, so nothing OCCT reports as an inapplicable 0 is surfaced as a measurement. The four flat accessors survive as computed properties returning Double?: lowerBound, upperBound, lowerTolerance, upperTolerance.

The ISO 286 tolerance class is now readable. Document.DimensionFormVariance (29 cases) and Document.DimensionGrade (20 cases) join DimensionType and GeomToleranceType, and Dimension.classOfTolerance reports the H7-style class OCCT stores outside the values array. Three new write methods reach the kinds: setDimensionBounds(at:lower:upper:), setDimensionClassOfTolerance(at:isHole:formVariance:grade:), and setDimensionTolerance(at:lower:upper:) which now returns false instead of a silent true when OCCT refuses the call, as it does for a dimension that is already a range.

The four enums are now gated against the pinned kernel. Scripts/derive-gdt-enums.py derives all 97 members from OCCT’s own headers into Scripts/occt-gdt-enums.txt and fails CI on any divergence, which nothing checked before: the bridge casts OCCT’s enum across unremapped, so a member OCCT adds used to become a silently dropped dimension.

Reproducer and measurements: Scripts/repro/996-gdt-read-surface/. The remaining accessor surface, 71 of 85 across the three XCAFDimTolObjects classes, is #1004.

Thread clocking now comes from the shared gp_Ax2 perpendicular basis (#990)

Shape.threadedShaft, Shape.threadedHole and Shape.threadedRod measure a thread’s start angle from a datum perpendicular to the thread axis. That datum was built by a cross(axis, worldUp) construction private to ThreadFeatures.swift; it is now the same deterministic perpendicularBasis(to:) (#881) that Placement, ConstructionPlane, Shape.sectionPlaneBasis and Drawing’s projection helpers already share, and which reproduces OCCT’s own gp_Ax2(gp_Pnt, gp_Dir) exactly.

Threads about +X, +Y and +Z are unchanged, bit for bit: measured against gp_Ax2 read from the pinned kernel, the old construction already agreed with the shared basis there. Threads about -X, -Y, -Z and oblique axes start at a different angle around the axis than they did before. Nothing in the API specifies a start angle, and a thread rotated about its own axis is the same thread, so this changes no dimension, volume or validity; it does change the exact vertex positions of a thread built about one of those axes, so a byte-comparison against a stored STEP/BREP of one will differ. A shaft and the hole it screws into, built on the same axis, remain clocked alike, and the clocking remains a deterministic function of the axis direction alone.

Measurement, reproducers and the reasoning for taking the basis’s second element rather than its first are in Scripts/repro/990-orthonormal-radial-basis/.

ThreadProfile’s built-in forms all come from one construction (#988, #991)

ThreadProfile.square and ThreadProfile.buttress are now built by the same truncated-trapezoid factory as whitworth55, acme29 and trapezoidalMetric30, rather than by literal vertex lists. square is that construction’s limiting case (crest flat = root flat = P/2, so the flanks become radial walls) and buttress is it with the crest flat centred at 0.2722 * P instead of at mid-pitch, which is what makes its load and clearance flanks differ. Both profiles’ vertices are bit-for-bit unchanged.

The thread cutter no longer re-derives the crest and root flat widths from the profile’s segments; ThreadProfile answers that itself, and the “is this segment a flat at this depth” test that hasCrestFlat, the direct rod build’s crest lookup and the cutter each spelled separately is now one predicate. No geometry changes.

ACME and Unified thread designations share one parse (#989)

ThreadSpec.parse(_:)’s ACME and Unified branches were the same imperial "<diameter>-<threads per inch>" parse written twice, including the inch-to-millimetre conversion. They now share one, so a fix to either reaches both. Every well-formed designation parses to the same form, diameter and pitch as before.

One malformed-input case changes answer: an ACME designation with a stray token between the thread count and the suffix, "1.5-4 x ACME", is now accepted (reading the thread count up to the first space) where it was previously refused. That leniency is what "1/4-20 UNC" requires, and it is the only input class whose result moved.

NLPlate G2/G3 lose a phantom iteration count, and Surface.plateErrors is removed (#999)

Surface.nlPlateDeformedG2(constraints:maxIterations:tolerance:) and nlPlateDeformedG3(constraints:maxIterations:tolerance:) are now nlPlateDeformedG2(constraints:tolerance:) and nlPlateDeformedG3(constraints:tolerance:). maxIterations was never read: NLPlate_NLPlate::Solve2 takes no iteration count, and IncrementalSolve, which takes a number of increments, is a different solver rather than a bound on this one and is already exposed as nlPlateDeformedIncremental(constraints:maxOrder:initConstraintOrder:nbIncrements:).

Surface.plateErrors(points:tolerance:maxDegree:maxSegments:) is removed with no replacement. It returned GeomPlate_BuildPlateSurface’s G0Error/G1Error/G2Error for a point-only plate, and the kernel assigns those three members only when the plate has curve constraints, leaving them uninitialised otherwise. Every value it ever returned was uninitialised memory: repeated calls on one fixture gave 1.94e-313, then -3.11e+231, then -nan, and none of tolerance, maxDegree or maxSegments changed any of them.

Seven more bridge parameters that did nothing (#999)

Three were replaced with the parameter the OCCT call underneath actually takes, one entry point became the OCCT call it was named for, and three were removed outright.

Curve2D.toBSpline(tolerance:) is now toBSpline(_:), taking a Curve2D.Parameterisation. Geom2dConvert::CurveToBSplineCurve has no tolerance, the conversion being exact for every parameterisation but .polynomial, and the parameterisation is the knob it does have: on a circle of radius 5 the eight cases give degrees 2 through 7 and 6 to 12 poles, with .polynomial the only non-rational and the only approximate one.

let circle = Curve2D.circle(center: .zero, radius: 5)!
circle.toBSpline()?.degree                 // 2
circle.toBSpline(.quasiAngular)?.degree    // 6
circle.toBSpline(.tangentHalfAngle1)       // nil, a full circle is past its documented limit

SAWireAnalysis.checkOuterBound(face:precision:) is now checkOuterBound(wire:face:), and it performs the check. It used to run a TopExp_Explorer and report whether the face had any wire at all, which is true of every valid face, so both its precision and its name were unbacked. It now calls ShapeAnalysis_Wire::CheckOuterBound. There is no precision, because that check consults none.

Curve2D.bisector(withPoint:origin:side:) is now bisector(withPoint:maxDistance:side:). Bisector_BisecPC::Perform takes no origin; the signature had been copied from the curve-to-curve sibling, where the origin is real. maxDistance trims the bisector, with OCCT’s own default of 500.

MedialAxis.init?(of:tolerance:) is now init?(of:), ExtremaElC.lineToEllipse(...tolerance:) drops its tolerance, and Curve3D.locateNearestPoint(_:initParam:tolerance:) drops its tolerance. None of the three underlying OCCT calls accepts one.

Every OCAF-family package now belongs to a pass of the refman-coverage epic (#973)

Forty-eight packages and 459 headers of the pinned kernel were named by no sub-issue of #807, the refman-coverage epic, and would have arrived unannounced at its final whole-surface pass. They are now partitioned, and the partition is a committed, re-runnable artifact:

python3 Scripts/repro/973-ocaf-package-partition/partition_census.py
python3 Scripts/repro/973-ocaf-package-partition/partition_census.py --verify-lanes

Two new passes were filed for the bulk of it, #982 (the OCAF framework layer: TFunction_, TPrsStd_, TObj_, AppStd_, AppStdL_) and #983 (OCAF persistence and format drivers: PCDM_, Storage_, FSD_, Plugin_, LDOM_ and every Bin*/Xml*/Std* driver package). Pass 4c (#813) gained BinTools_ and Resource_, Pass 4d (#814) gained StdPrs_ and StdSelect_, and Pass 3’s (#810) lane text was corrected to name the six packages its own census already claims. StdFail_ is recorded as belonging to no lane pass on purpose, with its reason.

The census exits 1 on any family package with no owner, on a header count that has drifted from the pinned kernel, and, under --verify-lanes, on an owning issue whose ## Lane no longer names what it owns. No Sources/ or docs/ change: this is ownership, not coverage, and each owning pass still runs its own per-class audit.

Document.assemblyItemCount(maxDepth:) reports when it could not finish counting (#964)

The bridge stops walking an assembly at 100,000 items, because XCAFDoc_AssemblyIterator keeps no visited set and a malformed self-referencing assembly would otherwise iterate to INT_MAX depth. It used to return the bound as though it were the total, so a document with more items was indistinguishable from one that genuinely had that many.

The bound remains, removing it trades a wrong answer for a hang, but it is now reported. assemblyItemCount(maxDepth:) returns Int?, and nil means the walk was truncated and the number would be a floor rather than a count. Migration: unwrap, if let n = doc.assemblyItemCount() { … }.

Transactions: a named transaction keeps its name, and commitWithDelta() returns a delta (#970)

Document.openNamedTransaction(_:) took a name and read it with nothing. The bridge called OpenCommand() and returned HasOpenCommand() ? 1 : 0; the argument was never touched.

The name now reaches OCCT. TDocStd_Document::OpenCommand takes no name, and the document’s own TDF_Transaction is constructed as "UNDO" with no setter, so an open transaction has nowhere to carry one. OCCT keeps a caller-supplied transaction name on the committed TDF_Delta instead, which is what TDocStd_MultiTransactionManager::CommitCommand(theName) does. The bridge holds the name until the transaction commits and writes it there:

doc.setUndoLimit(10)
doc.openNamedTransaction("add part")
// ... make changes ...
doc.commitWithDelta()?.name        // "add part"

abortTransaction() discards it, an openTransaction() with no name of its own supersedes it, and a named open that OCCT refuses, because one is already running, reports 0 and leaves the running transaction’s name alone.

Document.transactionNumber returned that same synthesized flag. It now returns TDF_Data::Transaction() through TDocStd_Document::GetData(). The values agree, and that is the finding rather than an excuse: measured against the pinned kernel, a document holds at most one framework transaction, so the number is 0 or 1 in every state its command API can reach. Nested transaction mode does not change it, because TDocStd_Document::OpenTransaction commits and reopens the same transaction rather than pushing a second one, and the depth stays at 1 through three nested opens. hasOpenTransaction carries the same information with the right type.

Document.commitWithDelta() could not return a delta at all, found while proving the first fix. Its first statement was SetUndoLimit(100), and TDocStd_Document::SetUndoLimit commits the open transaction before it changes the limit, so the CommitCommand() that followed had nothing left to commit and the function returned nil for every transaction that had one open. The call is gone: the undo limit is the caller’s to set with setUndoLimit(_:), and with undo disabled, which is OCCT’s default, there is no delta to hand back.

Scripts/repro/970-transaction-api/ carries the probe, its transcript, and the measurements docs/reference/Document-OCAF-Attributes.md now cites.

Sub-shape enumerations are complete or empty, never short (#979)

Shape.faces(), Shape.edges(), Shape.subShapes(ofType:) and Shape.orientedFaces() each skipped an element whose bridge handle came back null. A position in these arrays is an ordinal, the same 0-based index face(at:), edge(at:) and subShape(type:index:) take, so a skipped element shifted every later one down and each then answered for its neighbour with no error and no diagnostic. Downstream that is a pick resolving, highlighting and reporting confidently about the wrong face.

All four now build every ordinal or return an empty array, so faces()[k].index == k and subShapes(ofType: t)[k] is subShape(type: t, index: k) hold by construction. Handles the refusal does not wrap are released rather than leaked, and subShapes(ofType:) additionally refuses a short bridge write instead of returning its prefix.

Measured before choosing: the bridge cannot actually produce a hole. Each entry point fills every slot or fails the whole call, and across a hostile 13-shape battery no indexed map held a null entry, no enumeration was unstable between independently built maps, and no entry failed its per-element downcast (Scripts/repro/979-subshape-index-identity/). So no reachable input behaves differently; what changes is that the guarantee now holds by construction instead of by accident.

visionOS and tvOS are documented as untested rather than supported (#978)

Package.swift declares both platforms and Scripts/build-occt.sh builds slices for both under BUILD_ALL_PLATFORMS=1, but nothing has been tested on either and the released OCCT.xcframework ships only the three core slices, so resolving the released binary does not link there. README’s status table said “Supported”; both rows now say “Untested”, with a note that a local kernel rebuild is what makes them buildable. Also corrects README’s kernel version (8.0.0p1 → 8.0.1) and package version (v1.0.0 → v3.0.0), both stale in the same lines. Documentation only.

Refman coverage audit, Pass 3: Document/XDE assembly (#810)

Scripts/repro/810-refman-document-xde/refman_census.py enumerates every OCCT class under TDocStd_*, TDF_*, TDataStd_*, TDataXtd_*, TNaming_*, XCAFDoc_*, XCAFApp_*, XCAFDimTolObjects_*, XCAFNoteObjects_*, XCAFView_*, XCAFPrs_*, CDF_* and CDM_* (278 classes) and verdicts each against Sources/OCCTBridge and docs/.

Fixed 36 doc attributions across 6 files that named an OCCT class or member the implementation does not use. Seven attributed OCAF save, load, create and session queries to XCAFApp_Application or CDF_Application; all seven run on TDocStd_Application, and XCAFApp_Application is deliberately not constructed anywhere in the bridge since #371 replaced the process-wide singleton with a private application per document. docs/reference/Document-Persistence-IO.md gains a “Why not XCAFApp_Application” section recording that divergence, and docs/thread-safety.md no longer describes document creation, in the present tense, as going through a singleton retired three releases ago. Eighteen more named a member the pinned kernel does not declare: TNaming_Tool::SameShape and XCAFDoc_AssemblyGraph::NbRoots do not exist at all, and TDataXtd_Presentation::GetColor, TDataStd_Expression::SetExpressionString, TDocStd_XLink::GetLabelEntry, TDataXtd_PatternStd::SetSignature, XCAFDoc_AssemblyItemRef::RemoveExtraRef and XCAFDoc_ShapeMapTool::Map are near-miss spellings of Color, SetExpression, LabelEntry, Signature, ClearExtraRef and GetMap. The rest named a class nowhere in the call chain, several semantically different from the one that runs: Document.selectShape uses TNaming_Selector::Select, which computes a name that survives later modification, rather than TNaming_Builder::Select, which records a raw select pair.

Document.openNamedTransaction(_:) and Document.transactionNumber now document what they do: the name argument is accepted and never recorded, and transactionNumber returns 1 or 0 rather than a transaction number. The underlying API defects are #970.

Recorded 155 previously-unrecorded unwrapped classes in docs/occtswift-wrapping-gaps.md: deprecated NCollection typedefs, OCAF undo/redo delta records, abstract bases, framework storage records, internal helpers, GD&T qualifier enums nothing reads, and capability covered by a wrapped sibling. No public API change.

OCCTDocumentIsLabelModified is documented against the attribute it actually reads (#971)

The bridge header comment on OCCTDocumentIsLabelModified contradicted itself, naming TDocStd_Modified on one line and denying it on the next. TDocStd_Document::GetModified() is a forwarder to TDocStd_Modified::Get(Main()), so the attribute on the root label is the mechanism and the denial was the wrong half; the comment now says so once. docs/reference/Document.md separately attributed Document.isModified(_:) to TDocStd_Document::IsModified, a method that does not exist in OCCT 8.0.1, and now names TDocStd_Document::GetModified.

Sources/OCCTBridge/include/OCCTBridge_Document.h comes off Scripts/style-manifest-bridge.txt in the same change, per the code-style rollout’s fix-what-you-touch rule. No behaviour change: the reformat was verified to leave the file’s token sequence byte-identical.

*Properties accessors no longer hand back a dangling handle (#965)

Nineteen *Properties accessors on Curve2D, Curve3D and Surface returned a value that stored the parent’s native handle without retaining it. The parent releases that handle in deinit, so a view outliving its parent was reading freed memory. The chained form crashed:

let radius = edge.curve3D?.circleProperties.radius   // SIGSEGV before this fix

Edge.curve3D builds a fresh Curve3D per read, so nothing held the parent past the expression. The documented workaround (binding the parent to a local first) was invisible at the call site, unenforceable, and silently worked whenever the parent happened to be stored somewhere.

Each view now stores its parent and reads the handle through it, so the chained form is correct and the workaround is unnecessary. The affected accessors are bezierProperties, circleProperties, coneProperties, cylinderProperties, ellipseProperties, hyperbolaProperties, lineProperties, offsetProperties, parabolaProperties, planeProperties, sphereProperties, sweptProperties and torusProperties, several of which appear on more than one parent type.

Each of the nineteen is now plainly Sendable instead of @unchecked Sendable. The conformance is unchanged from a consumer’s point of view; it is now compiler-checked rather than asserted.

Scripts/check-borrowed-handles.py is a new static gate, run by ci.yml’s gate-scripts job: it fails on any struct or enum in Sources/OCCTSwift that stores an OCCT*Ref, since neither has a deinit to release one.

Pass 3: Document/XDE assembly duplication audit (#384)

Five duplications found and fixed in the XDE/OCAF document layer, all internal. Grouped by what changed for a consumer, which for this pass is nothing.

Internal only, zero behaviour change

  • OCCTDocumentCreate and OCCTDocumentLoadSTEP duplicated the whole XCAF document-creation prologue and the three-tool epilogue; both now call occtDocumentInit (#949).
  • The helper then moved from a file-static in OCCTBridge_Document.mm to OCCTBridge_Internal.h as inline, and all fourteen document-creation sites route through it, the other twelve live in OCCTBridge_IO.mm and could not reach a file-static (#957). Six of those twelve proceeded without the IsNull() check their eight siblings performed; all fourteen now share one guarded path. This is consistency, not a crash fix: a probe committed at Scripts/repro/957-newdocument-null/ measures TDocStd_Application::NewDocument("MDTV-XCAF", doc) against the pinned kernel and it does not return null, so the unguarded branch was never reachable. The asymmetry was real; the defect was not.
  • OCCTDocumentNamingTraceForward/Backward differed only in iterator type and are now one function templated on it (#950).
  • OCCTDocumentReadingFormats/WritingFormats differed only in which member function they called and now share a helper parameterised by it (#951).
  • Document.readingFormats/writingFormats and tracedForward/tracedBackward were the same duplication one layer up, byte-identical apart from the bridge symbol; both pairs now delegate to private helpers taking the bridge call as a parameter (#952).

Documentation and tooling

  • Document.swift and ten further files in this lane (Color, Material, BillOfMaterials, DocumentError, AssemblyGraph, AssemblyItemId, AssemblyNode, GDTInfo, GDTWrite, Annotation) are brought swift-format clean and removed from Scripts/style-manifest-swift.txt. That is 91 violations in Document.swift and 372 across the other ten, plus sixteen parameter lists in Document.swift written as - Parameters: x: on one line, a shape swift-format accepts and DocC does not render.
  • OCCTBridge_Document.mm is likewise clang-format clean and off Scripts/style-manifest-bridge.txt.
  • Document.assemblyItemCount(maxDepth:) gained a - Warning: recording that the bridge stops counting at 100,001 and returns that as the total, filed as #964. Superseded within this same release by the fix below, which makes the truncation reportable.

symmetryAxes honours fractionalTolerance in its existence gate (#1497)

OCCTShapeSymmetryAxes took a fractionalTolerance argument, passed it to the per-axis comparisons, and then called GProp_PrincipalProps::HasSymmetryPoint() and HasSymmetryAxis() with no argument at all. Both take an optional tolerance and default to Precision::Confusion(), so the gate deciding whether a shape has symmetry ran at a fixed tolerance while the gate deciding which axes to report ran at the caller’s. A shape symmetric only within a loosened tolerance was refused before the caller’s tolerance was ever consulted.

Both calls now take fractionalTolerance. A caller passing a larger tolerance gets the axes it asks for; a caller passing the default sees no change.

Sheet and ProjectionSymbol render onto a writer chosen at runtime (#1267)

Sheet.render(into:) and ProjectionSymbol.render(_:at:into:) existed only as three concrete overloads over DXFWriter, PDFWriter and SVGWriter, so code picking a format at runtime had no type to hold the writer in.

New public protocol DrawingWriter (DrawingDispatch.swift) declares the subset those two render paths need, plus entityCounts:

let writer: DrawingWriter = useSVG ? SVGWriter() : DXFWriter()
sheet.render(into: writer)
ProjectionSymbol.render(.first, at: .zero, into: writer)

DrawingWriter and the internal DrawingPrimitiveSink are two separately declared protocols with identical requirements rather than one refining the other: a public protocol cannot inherit an internal one, and #1180’s visibility constraint on renderScaffolding is why DrawingPrimitiveSink cannot be widened. The two new overloads therefore switch on the concrete type onto the three existing overloads. That switch is exact rather than a fallback guess, because DXFWriter, PDFWriter and SVGWriter are the protocol’s only conformers.

Additive. The three concrete overloads are unchanged.

Sixty-five OCCT: attributions in docs/reference/ named a method the bridge never calls (#1044)

The #928 census checks every Class::Member attribution in the docs against that class’s own pinned header. Outside the features lane it had never been run to completion, and sixty-five attributions across twenty docs/reference/ pages named something that does not exist or does not do the work. gp_Quaternion::GetVectorPart stood where GetMatrix belonged, math_Householder::Solve where Perform belonged, OSD_Path::IsRelative/IsAbsolute where IsRelativePath/IsAbsolutePath belonged, and BRepGProp_Domain::NbEdges, which has no such member, stood for an Init/More/Next iteration.

refman_census.py now checks attributions for every class rather than only lane classes, and carries a METHOD_ATTRIBUTION_ALLOWED set of fifteen pairs that are deliberate: enum values (Graphic3d_Camera::Projection_Perspective), documented kernel internals in docs/thread-safety.md, members removed by a version bump and recorded in docs/occt-upgrades.md, and doc-level concepts that stand for a Swift wrapper rather than one OCCT call (ShapeAnalysis_CanonicalRecognition::IsCanonicalSurface). Each carries its reason inline.

Documentation only. No API or behaviour change.

checkOuterBound refuses a partial pcurve set and an area that cancels to rounding (#1073)

OCCTWireCheckOuterBound guarded against a wire where no edge carried a pcurve on the face, because ShapeAnalysis::TotCross2D would then sign an area nothing contributed to. The quantifier was wrong. TotCross2D skips an edge with no pcurve rather than failing on it, so a wire where some edges carry one produces a signed area contributed by only the pcurved subset, and the verdict reads as geometry when it is an artefact of which edges happened to project.

The guard now requires every edge to carry a pcurve. A second refusal is added for the case the first cannot see: a wire whose projected area cancels to rounding. The returned TotCross2D magnitude is tested against the face’s own UV bounds from ShapeAnalysis::GetFaceUVBounds, and an area below 1e-12 of that scale is refused, which is far below any real outer/inner distinction and far above cancellation noise.

Both refusals return -1, which Shape.checkOuterBound already surfaces as nil under #1058’s tri-state encoding. A wire that previously received a verdict whose sign was numerical noise now receives no verdict. Callers already unwrapping the Optional need no change.

Edge.adjacentFaces(in:) returns every adjacent face, not the first two (#1087)

Breaking. The return type changes from (Face, Face?)? to [Face]?.

// before
if let (f1, f2) = edge.adjacentFaces(in: shape) { ... }

// after
if let faces = edge.adjacentFaces(in: shape) { ... }

An edge can bound three or more faces in a compound whose solids share a face. The old signature could express two, and returned whichever two TopExp::MapShapesAndAncestors listed first with no signal that it had truncated. Measured on two solids sharing one cut face, each of the four shared edges is bounded by four face occurrences, and the caller was handed two of them.

New bridge entry point OCCTEdgeGetAdjacentFacesArray fills a caller-allocated array and returns the true count, so the truncation point is the caller’s buffer rather than the signature. Edge.swift passes 64. OCCTEdgeGetAdjacentFaces is kept and marked deprecated in the header for the same reason it always worked: two faces is the right answer for a manifold edge.

Migration: destructure the array instead of the tuple. faces[0] for the old face1, and faces.count > 1 ? faces[1] : nil for the old face2, or handle the whole set where the shape can be non-manifold.

Null-wire guards on nineteen ShapeAnalysis_Wire bridge functions (#1099)

Nineteen bridge functions calling ShapeAnalysis_Wire::Init tested only that their OCCTShapeRef pointers were non-null, then handed the wrapped TopoDS_Shape to OCCT. A nullified or wrong-typed shape reached Init and took the process down with an uncatchable SIGSEGV, since OCC_CATCH_SIGNALS is inert in this build.

All nineteen now guard with occtShapeIsType(wire, TopAbs_WIRE) and occtShapeIsType(face, TopAbs_FACE), which is the guard #1058 already applied to OCCTWireCheckOuterBound:

OCCTWireCheckOrder            OCCTWireCheckLacking          OCCTWireMaxDistance2d
OCCTWireCheckConnected        OCCTWireEdgeCount             OCCTWireCheckConnectedEdge
OCCTWireCheckSmall            OCCTWireMinDistance3d         OCCTWireCheckSmallEdge
OCCTWireCheckDegenerated      OCCTWireMaxDistance3d         OCCTWireCheckDegeneratedEdge
OCCTWireCheckClosed           OCCTWireMinDistance2d         OCCTWireCheckGap3dEdge
OCCTWireCheckSelfIntersection OCCTWireCheckGaps2d
OCCTWireCheckGaps3d           OCCTWireCheckEdgeCurves

Each returns the refusal it already gave a wrong-typed input, so no correct call changes behaviour.


v3.0.0

The last seven bridge sibling-entry-point pairs share their scaffolding (#794)

Completes the #784 duplication rescan’s eleven-pair census, four of which landed earlier. Each pair duplicated its setup and extraction wholesale and differed only in the one OCCT call or constructor overload in the middle: OCCTExtremaPCCurve/Bounded and OCCTCPntsUniformDeflection/Range (Curve3D.mm), OCCTShapeMakePeriodic/OCCTShapeRepeat and OCCTWireInterpolate/WithTangents (Modeling.mm), OCCTImportSTL/Robust, OCCTExportPLY/WithOptions and OCCTDocumentWriteOBJ/WritePLY (IO.mm), and the OCCTSolveQuadratic/Cubic/Quartic trio (Spatial.mm), which differed only in constructor arity.

This is the shape that let #761’s buffer cap and PR #768’s dropped alpha channel survive: with two copies and no shared helper, a fix applied to one silently misses callers of the other.

OCCTShapeQuilt/OCCTShapeQuiltWithHistory is the one pair left unshared, deliberately. Factoring it broke quiltBoxFacesWithHistory, so both paths keep their own BRepTools_Quilt handling rather than shipping a shared helper that passes review and fails the test.

Sources/OCCTBridge/src/{Curve3D,IO,Spatial}.mm come off the clang-format exemption manifest as part of the same work. docs/guides/cookbook/quilting.md is new, documenting the quilting API the pair above backs. Internal only, no public API or behaviour change.

bounds, size and center are Optional, and “no bounding box” now comes from OCCT (#943)

Shape.bounds, Shape.size, Shape.center, Wire.bounds, Edge.bounds and Face.bounds return nil for a shape with no bounding box instead of fabricating (0,0,0)-(0,0,0), which was indistinguishable from a genuine zero-size shape at the origin (#834 documented this and left it in place). Shape.boundingBox already behaved this way, so the two no longer disagree.

The verdict comes from OCCT’s own Bnd_Box::IsVoid(), reported across the bridge as a Bool: OCCTShapeGetBounds, OCCTFaceGetBounds, OCCTFaceGetBoundsExact and OCCTEdgeGetBounds now return bool like OCCTShapeBoundingBox already did, and all six share one helper (occtComputeBoundingBox), which is the single place reading IsVoid(). That helper being file-static in OCCTBridge_Topology.mm is why OCCTShapeGetBounds, over in OCCTBridge_Properties.mm, was the one bounds entry point with no guard at all.

A Swift-side comparison of the returned coordinates against zero would be the same fabrication in a new place: a vertex at the world origin measures exactly (0,0,0)-(0,0,0) through BRepBndLib::AddOptimal (measured, Scripts/repro/943-bounds-void-vs-zero/). Through the ordinary BRepBndLib::Add it measures ±1e-7, because BRep_Tool::Tolerance floors every tolerance at Precision::Confusion(), so the sentinel would have misfired on one path and not the other.

Migration: unwrap. shape.bounds.max becomes shape.bounds?.max, or guard let b = shape.bounds. AAG drops any face with no bounding box rather than force-unwrapping it.

docs/SEMVER.md’s v2.0.0 break table gains #595, and an orphaned doc comment goes (#829, #877)

The v2.0.0 break table listed seventeen entries and omitted #595, which changed six curvature getters from Double to Double?. That is a compile error for any caller, and the omission was found the expensive way: building a real downstream consumer (swiftGCS) against v2.0.0 broke at two call sites that reading the table could not have predicted. All six are now listed with their migration: Curve2D.curvature(at:), Curve3D.curvature(at:), Curve3D.localCurvature(at:), Surface.gaussianCurvature(atU:v:), Surface.meanCurvature(atU:v:), Shape.edgeCurvatureLP(at:). Separately, a duplicated doc comment orphaned before a // MARK: in Shape+Modeling.swift was deleted; the method it described already carries the same text. Docs only, no API change.

Four more bridge sibling-entry-point pairs share their scaffolding (#794)

OCCTFilletBuilderGenerated/Modified and OCCTChamferBuilderGenerated/Modified each duplicated the whole list-to-C-array marshalling loop, differing only in which OCCT member function ran, and OCCTMeshUnion/Subtract/Intersect triplicated the mesh-to-shape roundtrip and re-mesh extraction. Factored onto occtFilletBuilderHistoryQuery, occtChamferBuilderHistoryQuery and occtMeshBoolean. Four of the census’s eleven pairs; the other seven remain open on #794. Internal only, no public API or behaviour change.

The int → TopAbs_Orientation decoder is shared, and three setters stop relying on undefined behaviour (#793)

oriFromInt (OCCTBridge_BRepGraph.mm) and intToOrientation (OCCTBridge_Topology.mm) were byte-equivalent reimplementations of the same four-case decode; both now call one occtOrientationFromInt in OCCTBridge_Internal.h. They agreed at the time, so this prevents drift rather than fixing a divergence.

One real behaviour change came with it. OCCTShapeSetOrientation, OCCTShapeComposed and OCCTShapeOriented did not use either decoder: they did static_cast<TopAbs_Orientation>(orientation) on the caller’s raw int, so a value outside 0...3 produced an out-of-range enum, which is undefined behaviour. They now route through the shared decoder, which saturates such a value to TopAbs_FORWARD. A caller passing a valid orientation sees no change; a caller passing an invalid one gets a defined, if lossy, result instead of undefined behaviour.

The Convert_* bridge helpers stop calling accessors OCCT marks deprecated (#801)

buildCurve2DFromConic and buildSurfaceFromElementary read poles, weights, knots and multiplicities one index at a time through accessors that Convert_ConicToBSplineCurve and Convert_ElementarySurfaceToBSplineSurface both mark Standard_DEPRECATED, each naming its batch replacement. Ten such call sites across the two helpers, not the four the issue first counted: the surface family has the same shape and needed the same fix. Both now use the batch forms, which return the NCollection_Array1/Array2 the helpers were assembling by hand. Not a defect today, since the deprecated accessors work; the risk is that this repo bumps its kernel regularly and a removed accessor becomes a build break during an upgrade. Internal only, no public API change.

Pass 2b left over-coverage behind: five doc attributions corrected (#930)

#928’s detector, run retroactively over Pass 2b’s lane with its six known findings excluded, surfaced 24 candidates; ten were adjudicated by reading each claim against the bridge body, its helpers and the pinned headers. Four were real, and a fifth sibling outside the sample shares the defect. Shape.vector2DCross(a:b:) and vector2DDot(a:b:) were documented as gp_Vec2d::Crossed and ::Dot when the implementations are inline arithmetic that constructs no gp_Vec2d at all; Shape.translated(from:to:) named gp_Vec where GC_MakeTranslation runs; Surface.torusAxis named gp_Torus::Axis where Geom_ToroidalSurface::Axis runs. The same ten-row exercise on Pass 2a’s lane produced zero real findings, so the four-to-one gap between the two lanes’ original counts was completeness, not subject matter. Docs only, no API change.

Three duplicated bridge wrapper pairs now forward to one implementation (#792)

OCCTShapeClean/OCCTBRepToolsCleanTriangulation, OCCTShapeUpdate/OCCTBRepToolsUpdate and OCCTBRepGraphMeshAppendCachedTriangulation/OCCTBRepGraphSetFaceTriangulationRep each wrapped one OCCT call under two names added in different releases, with no cross-reference between them. Each pair keeps one real implementation and forwards the other. Both names remain, so nothing calling either changes. This is the shape that let #761’s buffer cap and PR #768’s dropped alpha channel survive: a fix applied to one name silently misses callers of the other. Internal only, no public API or behaviour change.

Upstream OCCT work moves to a persistent branch checkout of our fork (#803)

Process and documentation only, no library change. Upstream patches are now developed in one persistent blobless checkout of gsdali/OCCT with Open-Cascade-SAS/OCCT as a second remote, shared across worktrees, instead of a shallow clone created per task and discarded. The old pattern is what closed OCCT#1417 (a git commit --amend against a --depth 1 clone produces a rootless commit, and GitHub closes the PR as unrelated history with no way to reopen it); that PR was reopened as OCCT#1457. Documented as §0 of okf/policies/upstream-occt-patch-process.md. The four upstream replies and test files staged under Scripts/repro/*/upstream/ now each carry a status header recording whether they were sent, so a file that was posted no longer reads as a pending draft.

SectionBuilder no longer returns stale results after a failed rebuild (#916)

ancestorFaceOn1(edge:) and ancestorFaceOn2(edge:) read post-build OCCT state gated on a bridge-side built flag that was only ever set true, never reset on a failed rebuild. A SectionBuilder reused across multiple build() calls could silently keep answering from a prior successful build’s geometry after a later build() call on the same instance genuinely failed, and, in the worst case (an argument that fails BRepAlgoAPI_Section::Build()’s own internal null-shape check), could crash instead of returning nil. Calling init1/init2 again after a successful build without a following build() call had the identical staleness gap. Fixed by resetting the tracked build outcome on every path that invalidates the previous result.

Refman coverage audit, Pass 2b: Selection/Construction (#809)

Scripts/repro/809-refman-selection-construction/refman_census.py enumerates every OCCT class under BRepExtrema_*, BRepClass*, gp_*, and GC_*/GCE2d_* (126 classes) and verdicts each against Sources/OCCTBridge and docs/. Fixed six docs/reference/Curve2D.md/ Curve2D-Analysis.md entries that cited the deprecated GCE2d_Make* class (a using alias for GC_*2d since OCCT 8.0.0) as the implementation behind Curve2D.arcOfCircle/arcThrough/ arcOfEllipse/arcOfHyperbola/arcOfParabola/segment(from:Point2D,to:Point2D), five of the six never call a GC_/GCE2d_ Make helper at all. Also found one live GCE2d_MakeSegment call in OCCTBridge_Modeling.mm still using the deprecated spelling, left as is and noted on #917 rather than forcing that grandfathered file’s ~24,000-line compliance sweep into this PR (see #917). Recorded 24 previously-unrecorded under-wrapped classes in docs/occtswift-wrapping-gaps.md, all internal algorithm plumbing, exception types, deprecated aliases, or capability already covered by a wrapped gce_* sibling. No public API change.

A file-size gate joins code-structure (#906)

Scripts/file-size-check.py and a code-structure workflow flag files that grow past the thresholds okf/policies/code-structure.md sets, so the “evict foreign material before splitting” rule has something enforcing it rather than only describing it. Tooling and CI only, no public API change.

The upstream-OCCT contribution policies are written down and merged into two (#858, #856, #874)

okf/policies/upstream-occt-patch-process.md is new and records how a carried patch becomes an upstream PR; okf/policies/upstream-occt-style.md gains the project’s actual GTest and formatting expectations, and the overlap between the two files was reconciled rather than left to drift. okf/references/carried-occt-patches.md was updated to match. Internal working policy only: nothing under Sources/ or docs/ changed, and no consumer-visible behaviour is affected. Recorded here rather than left absent because the merge-history audit (Scripts/check-changelog-transcription.py) reports any merge with neither an entry nor a No-Changelog: trailer, and these three predate the trailer being used.

Kernel rebuilt: v3.0.0-kernel.1 carries seventeen patches, up from fifteen

Package.swift now pins the v3.0.0-kernel.1 kernel pre-release (V8_0_1 plus carried patches 0010-0012 and 0014-0027), replacing the v2.0.0 asset’s fifteen. The two additions are 0026 (#905, BRepOffsetAPI_ThruSections refuses a loft whose non-planar extremity could not be capped, instead of marking it Closed(true)) and 0027 (#913, CreateSmoothed refuses mismatched section edge counts under checkCompatibility(false) instead of overrunning a fixed-stride array). Both fixes previously existed only in the source tree, so a consumer installing the package received neither, and neither was exercised by any CI job. No public API change.

Three verification facts are recorded in Package.swift rather than left to be rediscovered. 0027 is the first carried patch a green build-and-test cannot vouch for, because its only test is OCCTSWIFT_LOCAL-gated and so never runs in CI. The suite total is blind to that: the same run reports 5614 tests whether the test executes or is skipped, so only the per-test line distinguishes them. And a kernel pre-release tag pointing at a commit that pins its predecessor is correct, not a mistake to be fixed, because the alternative ordering is circular.

ThruSectionsBuilder.setCriteriumWeight rejects negative weights (#919)

setCriteriumWeight(w1:w2:w3:) now returns Bool indicating whether all three weights are non-negative. Previously, OCCT’s SetCriteriumWeight silently ignored negative weights (setting an internal Failed status that Build() then unconditionally overwrites with Done), making the rejection unobservable. The bridge now validates before calling OCCT and returns false if any weight is negative. The method is @discardableResult, so existing call sites compile unchanged.

Over-coverage detector for the #807 refman-coverage audit (#928)

Scripts/census-doc-occt-attribution.py checks every OCCT class a doc claim attributes a documented method to, against the pinned kernel’s headers and against what that method’s bridge function actually reaches. Shared by all twelve passes rather than written per lane. Validated one finding at a time against the 32 that #808 and #809 confirmed by hand: 25 of #808’s 26 and 6 of #809’s 6. It reports rather than gates, exiting 0 like census-unmeasured-values.py with CI running only its --self-test, because the measured false-positive rate is 41% over a 40-row hand-adjudicated sample committed under Scripts/repro/928-over-coverage-detector/. Its retro pass over the two finished lanes found no new over-coverage in #808’s and four confirmed findings in #809’s, filed as #930.

Scripts/repro/809-refman-selection-construction/refman_census.py no longer prints over: 0 as though it were measured. Nothing in that script can return over; the line came from a hardcoded four-name tuple beside three derived counts. It now prints the three reachable verdicts, asserts the fourth stays unreachable, and states that a clean regression check means its six known findings stayed fixed, not that the lane has none. The same fix was made to #808’s copy in ff2c852.

No public API change; tooling, CI and one census script only.

Refman coverage audit, Pass 2a: Shape/Topology core (#808)

Scripts/repro/808-refman-shape-topology/refman_census.py enumerates every OCCT class under TopoDS_*, TopExp*, TopTools_*, BRep_*, BRepBuilderAPI_*, BRepPrimAPI_*, BRepAlgoAPI_* and BRepCheck* (151 classes) and verdicts each against Sources/OCCTBridge and docs/.

Fixed 26 doc attributions across 12 files that named an OCCT class the implementation does not call. Five named TopExp_Explorer, which counts one entry per sub-shape occurrence, where the bridge calls TopExp::MapShapes / MapShapesAndUniqueAncestors, which count one per distinct sub-shape (Shape.edgeCount, uniqueEdgeCount, uniqueSubShapeCount(ofType:), edgeFaceAdjacency(), vertexEdgeAdjacency()). The rest named a class that is nowhere in the call chain, several of them semantically different from the one that runs: Shape.isClosedShape reads TopoDS_Shape::Closed()’s stored flag rather than computing closure with BRep_Tool::IsClosed, Shape.moved(dx:dy:dz:) attaches a TopLoc_Location rather than rebuilding geometry with BRepBuilderAPI_Transform, and Shape.quilt(_:) uses BRepTools_Quilt, which needs faces that already share edges, rather than BRepBuilderAPI_Sewing, which does not. OCCTMakeEdgeError’s bridge header comment claimed it returns a BRepBuilderAPI_EdgeError; it returns a BRepCheck_Analyzer verdict, because MakeEdge::Error() is a property of a live builder and cannot be recovered from a finished TopoDS_Edge.

Recorded 85 previously-unrecorded unwrapped classes in docs/occtswift-wrapping-gaps.md: deprecated NCollection typedefs, B-Rep storage records, abstract bases, internal helpers, enums whose values the Swift surface already mirrors, and enums OCCT 8.0.1 has no entry point returning. No public API change.

ThruSectionsBuilder no longer returns wrong or crashing results for a mismatched section edge count under checkCompatibility(false) (#913)

BRepOffsetAPI_ThruSections::CreateSmoothed() derived the edge count it assumes every section has from section 1 alone, and filled a fixed-size array on that assumption with no bounds check. With checkCompatibility(false), nothing reconciles differing section edge counts first (the default, checkCompatibility(true), does this via BRepFill_CompatibleWires). A later section with more edges than section 1 overran the array, heap corruption, observed as a SIGSEGV/SIGBUS once at least 3 sections are involved (2 sections always take a different code path that doesn’t share this allocation shape). A section with fewer edges didn’t crash, but silently misaligned per-section strides to the wrong geometry, reporting build() == true for an invalid result. Both directions are the same contract violation and are now rejected the same way. Fixed upstream: OCCT#1466, carried as Scripts/patches/0027-* and verified against a full local kernel rebuild (all 17 carried patches, full swift test passing).

Code-style CI: swift-format, SwiftLint, and clang-format gates (#876)

Adds automated style enforcement for new/touched code: swift-format (Swift formatting), SwiftLint (scoped to orphaned_doc_comment), and clang-format (the OCCTBridge C++ layer, using OCCT’s own style). Pre-existing files are grandfathered on a manifest until touched. No public API change; tooling and CI only.

Pass 2a: Shape/Topology core duplication audit, 25 findings plus #796’s census resolved, several were live bugs

A duplication audit of Shape.swift’s domain-split descendants (Shape+Analysis, +Curve, +Geom2d, +Math, +Mesh, +Modeling, +ShapeHealing, +Surface, +Topology) plus Edge/Face/Wire/WireOrder/TopologyRef (#382, part of the #377 segmented audit). Grouped by what actually changed for a consumer:

Fixed: already-diverged duplicate copies, i.e. real bugs

  • FaceFixer.Status’s raw Int32 values were wrong from .fail1 onward (shifted by one ordinal against the real ShapeExtend_Status enum, .done queried ShapeExtend_FAIL8 instead of the combined DONE flag) since the type was added; corrected. ShapeFixer gains a type-safe status(_ status: ShapeFixStatus) -> Bool overload sharing the corrected type; the legacy status(Int) is unchanged. (#849)
  • Shape.fixed(tolerance:fixSolid:fixShell:fixFace:fixWire:)’s fixShell/fixFace/fixWire parameters were accepted and silently discarded, only fixSolid ever reached ShapeFix_Shape. Wired to FixFreeShellMode()/FixFreeFaceMode()/FixFreeWireMode() (which govern free, unattached content specifically). Existing callers passing non-default values for these three will see different, correct output. (#837)
  • Shape.uniformAbscissa(distance:)/(distance:u1:u2:) had no ceiling at all, unlike their pointCount: siblings, a small enough distance could ask OCCT to discretize an unbounded number of points. Both now derive the implied count from curve length and reject before calling OCCT. (#853)
  • Shape.bounds fabricated (0,0,0)-(0,0,0) for a void shape as if it were real geometry, unlike the identical Shape.boundingBox computation, which correctly signals failure. Bridge hygiene fixed (explicit IsVoid() guard); the full fix (bounds becoming Optional) is a breaking change to 20 call sites and is documented as a candidate follow-up, not executed here. (#834)
  • Shape.droppingSmallEdges(tolerance:) defaulted to 1e-6 against the identical fixSmallEdges(tolerance:dropSmall: true)’s 1e-7, aligned to 1e-7. (#839)
  • Shape.classifyPoint2d(u:v:tolerance:) defaulted to 1e-7 against Face.classify(u:v:)/ Shape.classifyPoint2D’s 1e-6 for the same UV-boundary question, aligned to 1e-6. (#840)
  • Shape.maxTolerance(type:)/minTolerance(type:)/avgTolerance(type:) used an Int encoding incompatible with maxTolerance(subShapeType:)’s real TopAbs_ShapeEnum ordinals (the same literal meant a different sub-shape type under each). Gained additive ShapeType-typed overloads that agree with the ordinal convention; legacy Int overloads unchanged, documented as legacy. The three new bridge functions backing them originally triplicated their body (differing only in a hardcoded mode literal); deduplicated behind one shared helper in the same review pass that caught it. (#833)
  • docs/thread-safety.md/docs/naming-conventions.md incorrectly claimed the no-argument Shape.deepCopy() uses BRepBuilderAPI_Copy with independent geometry. It actually uses TNaming_CopyShape::CopyTool and only clones topology, Geom_Surface/Geom_Curve/ Poly_Triangulation handles are shared with the original. Docs corrected on all three copy methods. Shape.isSelfIntersecting(hardTimeout:)’s reliance on this for background-thread isolation is flagged as a latent risk, not changed. (#831)

Breaking change

  • Selector.SubShapeType.compsolid is renamed compSolid, matching ShapeType’s casing (the two had drifted). Shape.ShapeFilterType’s RawValue changes from Int32 to Int as part of becoming a ShapeType typealias. Migration: rename .compsolid → .compSolid; if code depends on ShapeFilterType’s raw type being Int32, cast explicitly. A third consolidated type, Shape.TopAbs_ShapeEnum, was originally deleted outright with no compatibility path, the only one of the four #844 consolidated without one, inconsistent with ShapeFilterType’s typealias and the transform-matrix methods’ deprecated overloads. Caught by the aggregate review; restored as @available(*, deprecated, renamed: "ShapeType") public typealias TopAbs_ShapeEnum = ShapeType before this PR went any further, so this is additive-again rather than a third breaking change. (#844)

Additive

  • Shape+Modeling’s six legacy fused/subtracted/intersected(tolerance:/glue:) entry points now delegate to union/subtracting/intersection, inheriting the #206 boolean-op timeout watchdog they previously lacked entirely. Each gained a timeout: parameter (default Shape.defaultBooleanTimeout, matching the safer family’s own parameter) so a caller needing longer can opt out of the new bound. (#832)
  • Shape.transformed(matrix:) now takes a new Matrix12Grouped type; transformed(byMatrix:)/ gTransformed(matrix:) now take TransformMatrix3D (previously only a TransformFactory3D return type, now also constructible directly). The two layouts these three methods use (GROUPED vs. INTERLEAVED) were previously indistinguishable [Double] arrays that could be silently swapped with no error; that’s now a compile error. Conversions (Matrix12Grouped.interleaved/TransformMatrix3D.grouped) are provided. The previous [Double]-taking overloads are kept as @available(*, deprecated) forwarders, existing source still compiles, with a warning. Both new types’ initializers were briefly a trapping precondition on a wrong element count instead of the graceful nil the methods they replace always guaranteed, a caller migrating off the deprecated overload with malformed/deserialized data would crash the process instead of getting nil. Caught by the aggregate review; changed to a failable init?(_:) before this PR went any further. (#835)
  • Shape.VolumeInertia/SurfaceInertia gain hasSymmetryAxis/hasSymmetryPoint fields, matching what InertiaProperties has always reported, read from the same already-computed GProp_PrincipalProps object, no extra cost. (#848)
  • Shape.faceFromPlane/faceFromCylinder’s uBounds/vBounds overload pair gains an additive tolerance: parameter (default 1e-7, matching prior silent behavior); now delegates to the uRange/vRange pair instead of duplicating the underlying BRepLib_MakeFace call. (#841)

Internal only, zero behavior change

  • TopologyRef’s occurrence-bounds guard (duplicated 4x) and ancestor-resolve switch (duplicated 2x) consolidated into two shared private helpers; three previously-untested failure branches gained regression coverage. The three resolver functions still each repeated the same switch/bind/early-return block to use those helpers’ Results, caught by the aggregate review and collapsed to .flatMap chaining, matching the .map style the same functions already used elsewhere. (#846, #854)
  • Face’s six-way-duplicated zero-mass centroid ternary consolidated into one massCentroid helper; isHorizontal is now provably isUpwardFacing || isDownwardFacing computed from a single normal fetch instead of two (a real fix for a review-caught performance regression: the first version of this consolidation fetched normal twice). Face.SurfaceType is now a typealias for Surface.SurfaceType. (#842, #843, #850)
  • ShapeContents/ShapeContentsExtended’s two independent 9-field mappings unified behind one internal ShapeContentsCore type, so the two can no longer silently transpose relative to each other. (#855)
  • WireOrder.analyze(edges:)/analyze(wire:) share one decode helper instead of duplicating the status/index-rebuild logic. (#845)
  • Shape.findSurface/findSurfaceEx/findSurfaceTolerance/findSurfaceExisted (5 entry points) share one internal BRepLib_FindSurface helper; gained a null-shape guard the 3 unguarded ones lacked (not reachable from Swift today). Two review passes caught and fixed three regressions the consolidation introduced along the way: an OCCTSurface allocation briefly sat outside its try/catch (risking a crash instead of a graceful nil on allocation failure), the shared accessor call briefly fate-shared callers that used to be independent (a tolerance-only caller could have failed for reasons that never touched tolerance before), and, caught only once the two fixes above landed and the aggregate diff was reviewed, the fix for the second regression still computed both ToleranceReached() and Existed() on every diagnostic call even though each of findSurfaceTolerance/findSurfaceExisted only ever reads one; a 3-way selector (OCCTFindSurfaceWant) now computes exactly the one each caller needs, matching the pre-consolidation code. All three fixed before this PR went any further. (#838)
  • Shape.orientedBoundingBoxDetailed shares its Bnd_OBB computation with orientedBoundingBox instead of computing a second one. (#847)
  • Shape.recognizeCanonicalSurface/recognizeCanonicalCurve, Shape.pointCloudByTriangulation/ pointCloudByDensity, Shape.coonsFilling/curvedFilling, Shape.discreteTrihedron/ correctedFrenet/draftTrihedron, and GuideTrihedronAC.evaluate/GuideTrihedronPlan.evaluate each share a private helper for their previously-copy-pasted marshaling. No behavioral divergence found in any of the 5 pairs, all pure duplication. (#796, all 5 pairs)
  • Shape.classifyPoint/classify(point:) now route through the same BRepClass3d_SolidClassifier mechanism (previously classifyPoint hand-built the lower-level pieces that class already wraps). (#851)
  • Shape.uniformAbscissa’s four overloads and uniformDeflection’s two overloads share their size-then-fill/malloc-then-unpack idioms; a review pass found and fixed a doubled arc-length computation the first version of the distance: ceiling fix introduced, replacing it with a purpose-built cheap quadrature bridge call. Shape.curveShapeIntersect/ShapeRayIntersection now cross-reference each other in their docs (two independent line/shape intersectors, neither aware of the other). (#852, #853)
  • nurbsConvertViaModifier()/convertedToNURBS() now cross-reference their real divergence (the former skips the latter’s final vertex-tolerance correction pass) instead of two undocumented, unrelated-looking entry points. (#836)

Add scope-boundary policy: extensions belong downstream, not in the kernel wrapper

Documentation only, okf/policies/scope-boundary.md names the test for whether a proposed feature belongs in OCCTSwift (a direct wrap of an OCCT operation) or in a downstream ecosystem package, with a table of which package owns which kind of extension.

ThruSectionsBuilder no longer returns stale results after a failed rebuild, or after a build on a changed builder that hasn’t been rebuilt (#910)

generatedFace(from:) and shape both read post-build OCCT state without reliably checking whether the last build() call on that instance actually succeeded, and without accounting for OCCT’s own internal state never resetting between builds or setting changes on a reused builder. Fixed in two rounds:

  • Outcome tracking. A ThruSectionsBuilder reused across multiple build() calls could silently keep returning a prior successful build’s geometry, through generatedFace(from:), shape, and build()’s own return value, after a later build() call on the same instance genuinely failed, including failures that OCCT’s own IsDone() does not reliably report on a reused builder (a punctual middle section added via addVertex()).
  • Stale binding after a later success. Even with outcome tracking, a builder that succeeded, then failed, then succeeded again could still have generatedFace(from:) answer with a face left over from the first success rather than the current one. OCCT’s internal edge→face map is additive-only. generatedFace(from:) now confirms its answer is actually part of the current shape before returning it.
  • Setting changes without a rebuild. setSmoothing, setMaxDegree, setContinuity, checkCompatibility, setParType, and setCriteriumWeight now invalidate a prior successful build the same way addWire/addVertex already did, previously, changing a setting after a successful build left .shape/generatedFace(from:) silently serving geometry built under the old setting until the caller happened to add a new section too.

Fixed by tracking the real build outcome bridge-side rather than trusting IsDone() alone, and by verifying generatedFace(from:)’s answer against the current build’s own shape rather than trusting OCCT’s internal map.

Document.constructionContext’s lazy-init was a check-then-set race; fixed, plus the rest of the #914 review’s second and third rounds

The one substantive bug, found in the review round after the round that added the concurrency hardening it slipped past. Document.constructionContext looked up an existing ConstructionContext and, on a miss, constructed and stored one, as two separate lock acquisitions with nothing atomic across them. Two threads’ first access to the same fresh Document could both miss, both construct, and the loser’s instance was returned to its own caller exactly as if it were live, but was immediately unreachable from the document once the winner’s store overwrote the table entry: anything added through the loser’s context silently disappeared, in a ConstructionContext nothing else could ever reach again. This sat directly under a DocumentAssociatedStorage this same branch had just finished hardening for a different purpose (EntityStore’s Value: Sendable, crossStoreLock) and had no coverage of its own. Fixed with DocumentAssociatedStorage.valueOrInsert(for:make:), doing the lookup-and-insert as one atomic step under a single lock acquisition. New regression test (DocumentConstructionContextRaceTests) races 8 concurrent first accesses against a fresh document across 200 rounds; proven per okf/policies/prove-the-test-fails.md, against the pre-fix check-then-set code it failed on the first attempt (2 of 200 rounds torn, one with all 8 tasks each constructing their own instance), against the fix 5 repeated full runs (5000 first-access races total) produced zero.

Everything else from the same two review rounds, all verified independently rather than accepted on the review’s word (a “reported (mechanism present, not yet observed)” claim was proven with a synthetic fixture and prove-the-test-fails both ways before being trusted):

  • count-operations.py, two more latent scanner bugs, both proven with synthetic fixtures: the public extension-body fix (finding 8, previous round) tracked implicit-public status per enclosing-scope frame, but a member’s own body never pushed a frame, so a local func nested inside a public extension’s member was itself miscounted as a public entry point, fixed by pushing a shielding, non-public frame for any non-type body (member, closure, if/for…) opening directly inside a public extension scope. Separately, the @available string-literal neutraliser (finding 7, previous round) ran on an already comment-stripped line, so a message containing an unescaped // (a URL) still truncated the string and re-triggered finding 7’s own “gate that cannot fail” failure mode through a different door, fixed by making the line-comment strip itself string-literal-aware. Also corrected a code comment that said 86 previously- undercounted members where the derived count’s own arithmetic (4,275 → 4,339, i.e. +64) already said 64.
  • Stale docs, one pre-existing (not from this branch’s own work), rest introduced by the prior round’s own fixes: the CHANGELOG’s #882 bullet wrongly listed spherical and surface-of-extrusion faces as using the axis-of-revolution branch, resolveFaceAxisDirection only ever allow-listed cylinder/cone/torus/revolution, and docs/reference/Construction.md already said so correctly; only the CHANGELOG bullet was wrong. Face.swift’s doc still pointed unwrapAxisComponents(_:) at ShapeAxis.swift, which the previous round’s own finding-11 move had emptied. docs/reference/Construction.md’s throughAxis entry hadn’t been touched despite two real behavior changes reaching it through resolveEdgeDirection (the curved-edge axis redirect, and the perpendicularBasis reference-perpendicular swap); alongEdge’s entry documented one of its two .degenerate failure messages but not the second, newer one.
  • Drawing.addAutoCentrelines/.addAutoCentermarks are affected by #881 too, and there the change is a fix, not a behavior difference: ground-truthed against the bridge, OCCTDrawingCreate builds its 2D projection frame from the same gp_Ax2-based basis perpendicularBasis(to:) now computes, so for any view direction other than ±Z these two helpers were placing annotations in a frame rotated/mirrored relative to the drawing’s own projected edges (worked example: a circle at world (0, 10, 5) viewed along (1, 0, 0), old centermark (10, 5), real projected circle at (5, -10)). Zero coordinate-level test coverage existed for this before now: Issue881PerpendicularBasisTests only checks the helper against gp_Ax2 constants in isolation, and the pre-existing DrawingAutoCentrelinesTests/ AutoCentermarksTests assert only counts and line style. New test (AutoCentermarkFrameAgreementTests) closes the gap directly: builds an off-axis cylinder, projects it, and compares addAutoCentermarks’s computed centre against the actual OCCT-projected circle’s centre read from drawing.visibleEdges’s own bounding box, not recomputed via perpendicularBasis(to:) a second time, which would just be the existing isolated test again. Proven per prove-the-test-fails: reverting perpendicularBasis(to:) reproduces the exact worked-example mismatch above; restored, both agree. Also moved perpendicularBasis(to:) itself out of DrawingAutoCenterlines.swift into its own file (PerpendicularBasis.swift), module-wide (3 of 5 call sites aren’t drawing code), the same misplacement the previous round’s finding 11 fixed for the bridge-unwrap helpers.
  • okf/policies/code-style.md’s unlabeled-tuple rule, applied consistently: six non-public declarations that returned a labeled tuple despite the policy’s own absolute rule (Face.uvMidpointSample(), Face.uvMidpoint, perpendicularBasis(to:), resolveEdgeDirection(_:), ConstructionContext.allEntitiesSnapshot, EntityStore.all) are now unlabeled, with every call site updated to destructure into named locals rather than losing the names to .0/.1, no call site’s actual field-name readability regressed, including uvMidpointSample()’s, whose callers genuinely read .point/.normal by name and are the case the policy’s own “labels were only ever documentation” rationale doesn’t cover on its face.
  • Minor cleanups, all reported findings, no behavior change: Selector.pickResults’s 0..<Int(count) range now carries an explicit assert documenting and enforcing that OCCTSelectorCollectResults (the shared helper behind all three OCCTSelectorPick* bridge calls) provably keeps count in [0, maxResults] on every path, measured against the bridge source, not assumed; an assertion rather than a silent clamp, since the invariant already holds and a clamp would paper over a future contract violation instead of catching it. Shape. sectionPlaneBasis’s auto-derive branch no longer discards perpendicularBasis(to:)’s up only to recompute the bit-identical value a second time via its own cross+normalize. axisDirectionAgreementCosineTolerance’s doc corrected: it’s compared as |dot| - 1|, not via acos, so the effective tolerance is sqrt(2 × OCCTPrecision.angular) ≈ 1.4 µrad, about six orders of magnitude looser than the 1e-12 rad the doc’s provenance argument implied, not a live bug (still ~0.14 µm of divergence on a 100mm part), just a wrong quantity in the reasoning. crossStoreLock’s doc clarified: it does not guard every ConstructionContext operation, only the cross-store ones it’s explicitly named for, plane(_:)/allPlanes/etc. take only their own per-store lock, a deliberate narrowing from the pre-#886 class-wide lock, not a regression the ambiguous old phrasing could be misread as.
  • Finding 4 (isCoplanar returning nil, not false, for non-parallel faces) and one further “reported” item (Shape+Analysis.swift’s undercounting claim) were investigated and found to need no change: isCoplanar’s nil-for-non-parallel behavior is already documented as intentional in its own doc comment, predating this review.

Verification for the whole round: swift build clean; full swift test, every touched test target run individually plus the full suite; all 6 static gates + their --self-tests clean; swift-format lint --strict + check-style-manifest.py clean (the two that broke CI on the previous round’s own response commit).

Unified the 5 duplicated “perpendicular basis from a direction” implementations onto OCCT’s own gp_Ax2 algorithm (#881)

Placement.init(origin:normal:), the .throughAxis construction-plane case, Drawing’s axis/point projection helpers, and Shape.sectionPlaneBasis’s auto-derive branch previously computed the basis perpendicular to a direction 3 different, mutually-inconsistent ways, a different worldUp fallback threshold, and in one case a reversed cross-product operand order that sign-flipped the result. All 5 now share one internal helper matching OCCT’s own gp_Ax2 canonical algorithm exactly, verified directly against gp_Ax2::gp_Ax2(const gp_Pnt&, const gp_Dir&)’s own source (gp_Ax2.cxx), not just the constructor’s name.

Behavior change, broader than first stated here: an earlier version of this entry claimed axis-aligned input was unaffected; that was checked against only one axis-aligned direction, not all six. Only world ±Z is unaffected, every other input, including world ±X and ±Y (the most common CAD section-plane normals and view directions), computes a different basis than before: e.g. for normal = (1, 0, 0), xAxis moves from world +Y to world +Z and yAxis from +Z to −Y. The old per-call-site cross(worldUp, direction) construction happened to coincide with gp_Ax2’s algorithm only at direction = ±(0, 0, 1), where the old code’s own degenerate-cross fallback branch was already firing. Confirmed by hand for all three axis pairs, not merely reasoned about; Issue881PerpendicularBasisTests gained axis-aligned cases in a follow-up commit to close the gap. This propagates to Placement(origin:normal:), .throughAxis, and, via Shape.sectionPlaneBasis’s auto-derive branch, Shape.section2D(planeNormal:): an X- or Y-normal section view built against a prior release renders rotated/mirrored relative to this one.

Drawing.addAutoCentrelines(from:viewDirection:)/.addAutoCentermarks(...) are also affected, and here the change is a fix, not merely a difference. Ground-truthed against the bridge, not inferred: OCCTDrawingCreate (OCCTBridge_Modeling.mm) builds the drawing’s own 2D projection frame from gp_Ax2(gp_Pnt(0,0,0), viewDir) via HLRAlgo_Projector, i.e. exactly the basis perpendicularBasis(to:) now computes. The removed per-call-site code these two helpers used (right = normalize(cross((0,0,1), viewDirection)), up = normalize(cross(viewDirection, right))) coincided with that only at viewDirection = ±(0,0,1), same as everywhere else in this entry, so for any other view direction, addAutoCentrelines/addAutoCentermarks were placing annotations in a frame rotated and mirrored relative to the drawing’s own projected edges, not merely a “differs from before” change. Worked example: a circle centred at world (0, 10, 5), viewed along (1, 0, 0): the old code placed its centermark at (10, 5); the drawing’s own projected circle (via gp_Ax2) actually draws at (5, −10). Now they agree. Issue881PerpendicularBasisTests pins perpendicularBasis(to:) itself against gp_Ax2 constants, not against the projector Drawing.project actually builds, and the pre-existing DrawingAutoCentrelinesTests/ AutoCentermarksTests assert only counts and style, never a coordinate, so this fix has zero direct coordinate-level test coverage; see the new test added alongside this note (#914 review, third pass, finding 2).

// Placement.init(origin:normal:) now derives its x/y axes the same way for every normal,
// matching the basis OCCT's own gp_Ax2 would derive from the same direction, including the
// common X/Y/Z-aligned normals, not just oblique ones:
let p = Placement(origin: .zero, normal: SIMD3(0.16, 0.21, 0.97))

count-operations.py fixed two undercounting bugs; headline moves 4,275 -> 4,339 (#914 review, findings 7 + 8)

Neither bug changed any Swift source, both are in the derivation script itself, so the true public-API count was always higher than what README/API_REFERENCE reported.

  • Finding 7: the @available(...) attribute scanner tracked its own argument-list paren depth by counting (/) across the whole pre-""" header of the line, including inside any ordinary (non-triple-quoted) message:/renamed: string. A single-line attribute whose message contains an unbalanced paren, e.g. message: "removed in 2.1 (see #123", left the scanner’s internal depth stuck above zero, which pins in_avail_attr true for the rest of the file: every later declaration silently stops being counted. Not observed in this tree’s current @available usage (none of its message strings contain an unbalanced paren today), but the mechanism was live and would recur on the next such message. Fixed by neutralizing complete "..." string literals before counting parens. Proven: a synthetic fixture (an unavailable declaration with an unbalanced-paren message, followed by two ordinary public functions) went from a derived count of 0 for the whole file to the correct 2, both before/after checked directly against the un-fixed and fixed script.
  • Finding 8: the FUNC/INIT/CVAR/SUBS matchers all anchor on a literal public keyword, but Swift raises a member’s default access level to match its enclosing public extension’s stated modifier, public extension Document { func foo() {...} } makes foo() genuinely public with no public keyword on its own line. 22 such blocks across Document.swift, DrawingSymbols.swift, DrawingThreadAnnotation.swift, PresentationMesh.swift, Shape+ShapeHealing.swift and Shape+Topology.swift were invisible to the scanner this way. This is the same defect that moved the count 4,267 -> 4,275 once already in this same PR, on a Shape+Analysis.swift reformat from public extension to extension + per-member public, caught by accident there, not by the scanner. Fixed at the root instead of by reformatting every remaining site: the scanner now tracks, per enclosing-scope stack frame, whether that frame is a public extension body, and treats an unmarked member as public there unless it explicitly narrows (private/fileprivate/internal). README/API_REFERENCE.md’s headline and Total move from 4,275 to the now-correctly-derived 4,339 (--fix); the illustrative categorisation percentage moves from ~78% to ~77% of the total for the same reason (its own row count is unchanged, only the denominator grew).

Selector’s three pick overloads share one OCCTPickResult -> PickResult mapping helper (#890)

Internal dedup, no behavior change: pick(at:)/pick(rect:)/pick(polygon:) used to carry three byte-identical copies of the closure that maps a raw pick-result buffer into [PickResult]. Now one private helper. No public API change.

Surface.torusAxis/Surface.revolutionAxis share a bridge-unwrap helper (#891)

Internal dedup, no behavior change: the two accessors used to duplicate the same six-variable out-param bridge-unwrap body they’d each hand-rolled independently. Now share a private helper.

Unify ConstructionContext/ConstructionLayer entity storage; fix planeShape/axisShape fallback asymmetry (#880, #886)

  • ConstructionContext’s plane/axis/point storage now shares one generic EntityStore implementation instead of three hand-written copies (#886). No public API change.
  • ConstructionLayer.materialize(in:graph:options:)’s three per-kind loops now share one materializeOne helper for the resolve/build/failure-reporting scaffolding (#886). No public API change.
  • Fixed a materialization-fallback asymmetry: axisShape’s Shape.face(from: wire) ?? attempt was provably dead code (an axis’s wire is never closed, so it can never succeed) and has been removed; planeShape deliberately does not gain the equivalent fallback, because the only input that would reach it is a placement corrupted by a zero-length plane normal, where a bare- wire fallback would silently add non-finite (NaN) geometry to the document instead of correctly reporting .planeShapeFailed (#880).
  • Fixed a second, separate bug found in the same PR’s own review: materializeOne wrapped Document.addConstructionShape’s result in .success without checking its own documented “negative on failure” contract, so a failed add was counted as materialized with a garbage negative labelId. Now guards labelId >= 0 and reports failure instead. Public API addition: ConstructionContext.MaterializationFailure gains three new cases, planeAddFailed(PlaneID), axisAddFailed(AxisID), pointAddFailed(PointID), distinguishing “the representative shape never built” (the existing plane/axis/pointShapeFailed cases) from “it built fine but Document.addConstructionShape rejected it” (these three). Any exhaustive switch over the enum outside this package needs updating for the new cases; MaterializationResult.failures can now surface them. No input reachable through the public API is currently known to trigger the new guard, confirmed no way to make addConstructionShape fail on a shape materializeOne just built successfully, so it’s exercised only via an injectable addShape closure kept internal for testing (#898, review finding 4; flagged as an undocumented public API change in the #914 review).
  • EntityStore<ID, Value> now requires Value: Sendable instead of leaving Value unconstrained under a bare @unchecked Sendable. The lock genuinely does make the class’s own storage thread-safe, but @unchecked disables Sendable checking for the whole type, including whatever Value turns out to be, an unconstrained Value would let a future non-Sendable payload cross concurrency boundaries through value(_:)/all with no compiler check at all. Costs nothing today: all three instantiations (ConstructionPlane/ConstructionAxis/ConstructionPoint) are already Sendable (#914 review, finding 13).

ShapeMeasurements.totalFaceArea and Shape.surfaceArea/surfaceInertiaProperties()/surfaceInertia are documented as separate measurements, not merged (#885)

ShapeMeasurements.totalFaceArea (a tolerance-controlled sum of per-face BRepGProp::SurfaceProperties integrals) and Shape.surfaceArea / Shape.surfaceInertiaProperties()?.mass / Shape.surfaceInertia?.area (one untunable, whole-shape BRepGProp::SurfaceProperties integral, shared bit-for-bit by all three) both answer “total surface area” but are different computations that can disagree, usually by an amount too small to matter, but tightening Shape.measure(linearTolerance:)’s linearTolerance moves only the first one, and can make the two disagree more, not less. No behavior changed; both are now documented with explicit cross-references and a measured example of the gap (docs/reference/Measurement.md, docs/reference/Shape-Features.md, docs/reference/Shape-Measurement.md), and a new regression suite (Tests/OCCTAnalysisTests/Issue885TotalAreaDivergenceTests.swift) pins the measured behavior.

ConstructionAxis.alongEdge now resolves the true rotation axis for cylindrical/conical edges instead of the endpoint secant (#883, #887)

ConstructionAxis.alongEdge(_:) previously computed only the secant between an edge’s two parameter-bound endpoints, contradicting its own doc comment: a full-circle edge (a hole rim, a closed fillet edge) failed with .degenerate("zero-length edge") since its endpoints coincide, and a partial arc on a cylindrical/conical edge returned the chord between its endpoints as a plausible-looking but wrong direction. It now reads the true rotation axis off the edge’s adjacent face via the existing Face.primaryAxis whenever the edge is non-linear and such a face exists, falling back to the endpoint secant for genuinely linear edges. Also deduplicated the five BRepGraph.resolve degeneracy checks (.byThreePoints, .throughPoints, .intersectionOfPlanes, .intersectionOfAxisAndPlane, resolveEdgeDirection) onto one shared helper, no behavior change, internal only.

boundingBox/boundingBoxOptimal no longer mistake a zero-coordinate shape for bridge failure (#900)

Shape.boundingBox and Shape.boundingBoxOptimal(useShapeTolerance:) used to infer bridge failure from “all six returned coordinates are exactly zero”, indistinguishable from a genuinely degenerate/point shape at the world origin, which legitimately computes to all-zero output. OCCTShapeBoundingBox/OCCTShapeBoundingBoxOptimal now return bool, backed by an explicit Bnd_Box::IsVoid() check, matching the radiusOfGyration/centroid pattern. nil now means only “the box is void”, a point-vertex shape at the world origin correctly returns (min: .zero, max: .zero) instead of nil:

let origin = Shape.vertex(at: .zero)!
origin.boundingBoxOptimal()   // was nil -> now (min: .zero, max: .zero)

boundingBoxOptimal had a live, demonstrated repro (BRepBndLib::AddOptimal on a point-vertex at the origin); boundingBox’s equivalent case is not reachable through any real shape today (OCCT’s BRep_Builder::MakeVertex floors vertex tolerance above zero), but was fixed for the same contract regardless.

Deduplicated the axis-unwrap (six-double) and point/vector-unwrap (three-double) bridge patterns across Surface/Curve3D onto two shared helpers (#899)

Surface.CylinderProperties.axis, .ConeProperties.axis, .PlaneProperties.pln, .torusAxis/.revolutionAxis, .SweptProperties.direction, Curve3D.CircleProperties.xAxis/ .yAxis, .EllipseProperties.directrix1, .HyperbolaProperties.asymptote1, .ParabolaProperties.directrix, .LineProperties.position/.lin, .d1(at:), and .evalD1(at:) (14 properties/methods total) each hand-rolled the same six-Double-out-param origin+direction unwrap. A further 29 accessors (Surface’s sphere/cone centers and apex, plane/BSpline/Bezier surface evaluators and poles, point(atU:v:), normal(u:v:), bsplineLocalDN; Curve3D’s circle/ellipse/hyperbola/parabola/line points and foci, Bezier/BSpline curve evaluators and poles, point(at:), bsplineLocalD0, bsplineLocalDN) hand-rolled the equivalent three-Double single-vector unwrap. Both patterns now go through one new pair of module-internal helpers in ShapeAxis.swift, unwrapAxisComponents(_:) and unwrapVectorComponents(_:). Pure internal refactor: no return type, argument label, or computed value changed for any of the 43 touched call sites.

Also (test-only, no source behavior change): six accessors’ tests (circleXAxis/circleYAxis/cylinderAxis/coneAxis/parabolaDirectrix/evalD1BSpline) checked only a single component or magnitude, and coneApex() made no assertion at all; all seven now check the full point/origin/direction the accessor returns.

Follow-up (#914 review, finding 10): unwrapVectorComponents(_:) only covers the always-succeeds shape (a Void-returning bridge call). 10 more three-Double-out-param sites in these same two files report success through their own Bool return instead, a different enough shape that the original 43-site sweep skipped them rather than mis-converting them. 8 of the 10 are a genuine point/vector (Curve3D.tangentDirection(at:), .normal(at:), .centerOfCurvature(at:), .planeNormal(tolerance:), .directionFrom2Points(_:_:), .offsetDirection; Surface.normal(atU:v:), .nlPlateDerivative(...)), now sharing a new sibling helper, unwrapVectorComponentsIfSuccessful(_:), that returns SIMD3<Double>? instead of always SIMD3<Double>. offsetDirection’s own public return type is a labeled (x:y:z:) tuple, not SIMD3<Double>; the shared helper’s result is unpacked into that shape rather than changing it, another pure internal refactor, no return type, label, or computed value changed at any of the 8. The remaining 2 (Surface.projectPoint’s (u, v, distance), .plateErrors’s (g0Error, g1Error, g2Error)) are left hand-rolled deliberately: neither triple is a point or a direction, and forcing non-vector values through a helper named for vector components would read worse than the three lines it would save.

Also (#914 review, finding 11): all three helpers (unwrapAxisComponents, unwrapVectorComponents, unwrapVectorComponentsIfSuccessful) moved from ShapeAxis.swift to SIMD3Unpacking.swift: module-wide bridge-unwrap helpers, not ShapeAxis-specific, and SIMD3Unpacking.swift is this project’s designated home for exactly this duplication class (created by #419 for unpackSIMD3, the equivalent helper for the batch/array case). No behavior change; internal free functions, visible identically from every file in the module either way.

unwrapAxisComponents returns a bare, unlabeled tuple so every call site is a one-liner (#903)

ShapeAxis.swift’s unwrapAxisComponents(_:) (added by #899/#902) returned a labeled (origin: SIMD3<Double>, direction: SIMD3<Double>), which Swift cannot implicitly relabel into a differently-labeled destination tuple, so 12 of its 13 call sites needed an intermediate binding plus an explicit relabel instead of a direct return. Changed the return type to a bare (SIMD3<Double>, SIMD3<Double>); all 12 now return the helper’s result directly, picking up whatever labels their own declared return type wants. Its sibling unwrapVectorComponents(_:) already returned a bare SIMD3<Double>, so this makes the two helpers consistent with each other. The 13th call site, the private Surface.axis(ifKind:_:) helper, gets the same bare return type for consistency, closing off the same relabel wall for any future caller wanting different labels. Pure internal refactor: no return type, argument label, or computed value changed on any public accessor. Added a codified convention for this shape to okf/policies/code-style.md, and filed #908 for the two further internal helpers (Face.swift’s boundsVia, SweepGuideTypes.swift’s evaluateGuideTrihedronD0) found to share it.

boundsVia and evaluateGuideTrihedronD0 return bare, unlabeled tuples (#908)

Face.swift’s boundsVia(...) (shared by Face.bounds/Face.exactBounds) and SweepGuideTypes.swift’s evaluateGuideTrihedronD0(...) (shared by GuideTrihedronAC.evaluate/GuideTrihedronPlan.evaluate) had the same labeled-tuple-helper shape #903/#904 fixed on ShapeAxis.swift’s unwrapAxisComponents(_:): a shared internal/private helper baking in its own tuple labels, which Swift cannot implicitly relabel into a differently-labeled destination tuple. Neither was a live bug (every current caller already used matching labels), but both hit the same wall the moment a caller wanted different ones. Changed both return types to bare tuples; boundsVia additionally now delegates to unwrapAxisComponents(_:) directly instead of hand-rolling a second copy of the same six-out-param unpack. Pure internal/private refactor: no public accessor’s return type, argument labels, or computed value changed. Added value-level tests for GuideTrihedronAC/GuideTrihedronPlan.evaluate (an orthonormal, right-handed frame check that catches any pairwise tangent/normal/binormal swap) and Face.exactBounds (a direct min <= max check per axis, per face), since the existing coverage for those two call sites was either non-nil-only or checked only a single component.

ConstructionPlane.tangentToFace, ConstructionAxis.normalToFace, ConstructionPoint.centroidOfFace now resolve correctly on curved faces (#879, #882, #884, #888, #889)

  • #879: ConstructionPlane.tangentToFace(face:at:) now evaluates the plane’s normal at the requested point’s own projected location on the face, not the face’s UV-domain midpoint. Fixes silently-wrong results on any curved face (cylinder, cone, sphere, torus, freeform); planar faces were already correct by coincidence and are unaffected.
  • #882: ConstructionAxis.normalToFace(face:at:) now uses the surface’s own axis of revolution (Face.primaryAxis) for cylindrical, conical, toroidal, and surface-of-revolution faces, matching its documented contract. Not spherical or surface-of-extrusion faces, corrected from an earlier draft of this bullet that wrongly listed both: resolveFaceAxisDirection allow-lists .cylinder, .cone, .torus, .revolution only (ConstructionEntity.swift), extrusion’s primaryAxis.direction is the sweep tangent, not a surface normal, and a sphere has no intrinsic rotation axis at all, so both take the same UV-midpoint-normal fallback as planar and freeform faces (see docs/reference/Construction.md’s normalToFace section for the full per-kind breakdown and why each is excluded) (#914 review, third pass).
  • #884: ConstructionPoint.centroidOfFace now resolves to the face’s real area centroid (Face.surfaceInertia.centerOfMass, the same value Shape.measure().faceCentroids reports), not a UV-parameter midpoint approximation. Fails with .degenerate(...) for a zero-area (or otherwise uncomputable) face instead of returning a fabricated point.
  • #888/#889: internal dedup only, no observable behavior change, the edge fraction→parameter and face UV-midpoint-sample formulas each now have one implementation instead of several copy-pasted ones.

Landed across four review rounds on the same PR (#897), three of which found further real issues in the same functions rather than just polish:

  • Round 2: Face.revolutionProperties no longer computes a spherical face’s radius from the axis-relative radial component (correct only by coincidence on an untrimmed sphere sampled at the equator), now the true, constant sphere radius. tangentToFace’s Placement.origin is now the actual on-face point the normal was evaluated at, not the raw at point verbatim (they could differ, landing the origin off the face’s surface, when at didn’t genuinely lie on face), and it now falls back to the UV-midpoint sample when Face.project(point:) itself fails to converge, not just when the subsequent normal lookup fails. normalToFace’s fallback for faces with no genuine axis (planes, free-form/BSpline surfaces) is now point-aware instead of always answering the fixed UV-midpoint normal. Source-breaking: unsignedAngle(between:and:) now returns nil for degenerate (near-zero-length) input, matching its own doc comment, instead of silently returning 0, return type changes from Double to Double?; every existing call site already only consumed it through a guard/optional-chain. That fix’s behavior change isn’t contained to unsignedAngle itself: Edge.angle(to:), Face.angle(to:), ConstructionAxis.angle(to:in:) and ConstructionPlane.angle(to:in:) all forward its result directly, so a degenerate tangent or normal (a zero-length edge, a cusp, a singular UV-midpoint sample) now makes each of those return nil too, instead of silently reporting 0 radians, a false “parallel” answer. Edge.isParallel (to:)/isPerpendicular(to:) and Face.isParallel(to:)/isPerpendicular(to:)/isCoplanar(with:) go through angle(to:) or normalsAreParallel(_:_:toleranceRadians:) (both new in this PR, #897 review finding 6/11) and so inherit the same nil-on-degenerate-input change one level further out (#914 review, finding 5).
  • Round 3: resolveFaceNormal’s Face.project(point:)-failure fallback (.tangentToFace) now returns a genuine on-face point as Placement.origin instead of the raw, unprojected input point (measured up to 5 units off the face’s actual surface). normalToFace’s returned origin is now the actual on-face point the direction was evaluated at, for the same no-primary-axis fallback case.
  • Round 4: resolveFaceAxisDirection’s cylinder/cone/torus/revolution branch now anchors the returned axis on the true rotation axis line instead of the caller’s raw, off-axis at point, measured 5 units off on a cylinder and 25 units off (its widest point) on a torus; the sphere/extrusion fallback branch had an analogous, lower-severity origin/direction mismatch, also fixed. revolutionProperties and resolveFaceAxisDirection now switch on the same ShapeAxis.Kind enum instead of two independently-maintained case-lists across Face.SurfaceType and ShapeAxis.Kind.
  • Also (internal, okf/policies/code-style.md): unified resolveFace(_:)/resolveEdge(_:) onto one generic helper, all now returning unlabeled tuples; deleted Edge.pointByLinearFraction(_:) (added mid-PR, zero production callers) after /ultrareview flagged it as dead code.

ThruSectionsBuilder(isSolid: true) no longer silently reports success on an uncapped loft (#905)

ThruSectionsBuilder(isSolid: true) used to silently omit both end-cap faces for a closed section wire with two or more periods of out-of-plane variation around the loop, a genuinely non-planar closed curve, not just one with nonzero Z spread. build() returned true, but shape.checkResult.isValid was false with errorCount == 0 and no localized error, and a subsequent .healed() call silently demoted the result from a solid to a shell rather than repairing it (root cause of #702). MakeSolid()’s capping helper (PerformPlan(), which only fits a plane or a surface already attached to a wire’s edges) already tracked whether capping actually succeeded in a local flag and discarded it, unconditionally marking the result Closed(true) regardless. Fixed in Scripts/patches/0026-*: throw instead, matching the pattern this same function already uses for a null shell. Filed upstream as OCCT#1462, validated via override-link. Not yet shipped: the fix is carried, not baked into this release’s xcframework, ThruSectionsBuilder(isSolid: true).build() for this input still reports the old, wrong true until the kernel is next rebuilt and re-pinned.


v2.0.0

  • AAG’s sharedEdgeCount is no longer silently capped at 10 by the bridge’s fixed edge buffer, which made a floor/wall pair sharing more than ten boundary segments under-report. (#761)

Every public member is now documented, and the census that measures that is fixed (#802)

docs/reference/ gained 1,249 sections and extended 208 more across 61 pages, taking check-docs-existence.py --coverage from 509 public members named nowhere in docs/ to 0, against 5,974 public members.

The census itself was wrong in two ways, which is why the reported gap was 1,487 rather than 509. It counted private/internal members, because it built on an extractor that is deliberately access-blind (correct for the staleness gate, wrong for a census of what consumers need), so it demanded reference pages for ~300 implementation helpers. And it counted only headings, so a field documented as a table row read as undocumented, which is what produced 584 empty anchor headings beside tables that already explained every field. Access is now resolved per member, honouring the rules that are not readable off the declaration line: an enum case takes its enum’s access, an unmarked member of a public extension is public while the same member in a bare extension is internal, a nested type in a public extension keeps its public let fields public, and no member is more visible than the type holding it. Any backtick-quoted identifier now counts as “named”. Twelve new self-test rows, each proven load-bearing by injection.

Also corrected while assembling it: ThreadBuild.boolean was still shown as a live enum case and described as behaving like .auto, when it was removed at v2.0.0 and a legacy "boolean" JSON key decodes to .direct; 745 headings that jumped two or more levels were re-levelled; and docs/occtswift-wrapping-gaps.md and docs/integration-tests.md both claimed 3,333 operations across 1,112 headers, unchanged since 2026-04-13, against a derived 4,256 across 1,166.

PDF/SVG/DXF exporters share one drawing-collection pipeline instead of three independent copies (#795)

PDFExporter.swift, SVGExporter.swift and DXFExporter.swift now route their edge, annotation and dimension collection through one shared implementation in DrawingDispatch.swift (DrawingPrimitiveSink, collectDrawing, collectProjectedEdges, strokeWidthMM, formatMM), instead of each independently hand-rolling its own copy. DXFExporter.swift’s own formatTolerance/TolerancedLabel and its separate dimension/annotation dispatch pipeline (emitLinear/emitRadial/emitDiameter/emitAngular/emitOrdinate, collectAnnotations/emitBalloon/emitCuttingPlaneLine/emitHatch) are removed in favour of the same shared dispatcher PDF/SVG already used, closing the gap where a tolerance-formatting fix made to the shared file could previously miss DXF entirely. No public API signature changed and output is byte-identical, verified by golden-output tests over every annotation and tolerance case. Internal only.

A new gate checks that every symbol docs/ documents as current API still exists in Sources/OCCTSwift (#802)

Scripts/check-docs-existence.py closes a gap none of the existing gates covered: during #798, two reference pages showed a live-looking signature for a symbol the same PR had deleted, caught only by a reviewer reading closely. The new gate checks every symbol docs/**/*.md and README.md document against Sources/OCCTSwift, keyed on the exact (owning type, member name) pair so a rename cannot collide with a same-named live symbol elsewhere (Shape.nbEdges, removed, vs. ShapeContentsExtended.nbEdges, live and unrelated). Wired into ci.yml’s gate-scripts job and the optional pre-commit hook alongside the other five gates.

Fixed 18 stale references it found: three sections (Curve3D.ContinuityOrder, Shape.BSplineContinuity, Surface.Continuity) still described a removed typealias as a live deprecated alias; Document-OCAF-Attributes.md’s whole TDocStd_PathParser section documented a type #784 removed outright (now points at OSDPath); a cookbook guide still called generation merely deprecated rather than removed; and 13 pages (headings and code snippets both) named a method that never existed under that name, unrelated to #784: Curve3D.helix, Shape.fillet, Shape.pipe, Mesh.fromShape/Mesh.from, Curve2D.rectangle/Curve2D.arc, Shape.makeFace/makePolygon, and Exporter.writeGLB/exportDXF.

Also corrected docs/reference/FeatureRecognition.md’s detectPocketsAAG() and detectHoles() sections, both of which still described their pre-#762/#747 criteria in full rather than the current mechanism.

61 deprecated symbols plus one bridge deprecation, adjudicated and removed (#784)

Every @available(*, deprecated) symbol in Sources/OCCTSwift (61) and the one __attribute__((deprecated)) bridge symbol (OCCTFacesAreAdjacent) were adjudicated one at a time and removed. None had a live caller anywhere in Sources/OCCTSwift or Sources/OCCTBridge, measured via swift build --build-tests’s own deprecation warnings, all 45 of which landed in Tests/ or Scripts/repro/censuses/, none in Sources/. See the SemVer impact section below for the full list of removed symbols and their replacements, and Scripts/repro/784-deprecations/adjudication.md for the row-by-row evidence.

Investigated OCCTFacesAreAdjacent specifically for the “replacement is worse” case: its replacement, adopted by its only caller since #783, costs a full walk of the face pair where the original stopped at the first shared edge, but that caller already needs the full walk for other reasons, so the cost is paid regardless of which function is called, and no other caller wanting pure adjacency exists. Removed under the same OCCTBridge-orphan precedent as #506/#651.

Tests/OCCTThreadTests/Issue254BuildModesTests.swift (#254) is kept, not deleted, and its surviving test renamed to autoMatchesDirect(). Its old allModesAreDirect() asserted three things: that a direct build is a smooth low-face helix (fDirect < 40, against the faceted cut’s roughly 893 faces), that .auto resolves to .direct for a single start (fAuto == fDirect), and that the now -removed .boolean matched .direct too (fBool == fDirect). Only the third concerned the removed case; the first two are the substance of #254 and were about surviving API. Deleting the whole file, as this PR first did, would have dropped coverage nothing else in the tree provides: Issue189ThreadGuardTests.swift, the only other file mentioning ThreadBuild, covers thread building and worm-pitch rejection, not this equivalence. The .boolean shaft, its helper, the third expectation, and the .boolean-only booleanIsInEnvelope() test (whose remaining claim, that the direct build’s crest is in-envelope, is already covered by Issue222EnvelopeTests.swift) are removed; the two surviving assertions are not.

README.md/docs/API_REFERENCE.md totals re-derived via count-operations.py --fix (4,306 to 4,254): not every removed symbol counted as a wrapped operation under that script’s rule (typealiases and an enum case do not).

#784 duplication rescan: a committed census artifact, six issues filed, no source changes

Added Scripts/repro/784-duplication-rescan/detect-duplicate-logic.py, a re-runnable k-token-shingle clone detector over the bridge’s C functions and the Swift API’s funcs, built to answer whether Pass 1a/1b (#380/#381) missed more of the duplication shape six accidentally-found instances revealed on 2026-08-07. It found 38 bridge and 21 Swift candidate pairs at its tuned thresholds; 6 issues (#791-#796) were filed from what measured as genuine, none fixed in this PR. No public API changed.

OCCTConvertSphereToBSplineSurface/OCCTConvertCircleToBSpline2D now share their siblings’ array-building helper (#791)

Internal-only bridge refactor, found by the #784 duplication rescan (PR #797). OCCTConvertSphereToBSplineSurface and OCCTConvertCircleToBSpline2D each reimplemented the pole/weight/knot-array-building sequence a shared helper (buildSurfaceFromElementary/ buildCurve2DFromConic) already provides and their Cylinder/Cone/Torus and Ellipse/Hyperbola/Parabola siblings already call, because both entry points predate the helpers’ extraction. Convert_SphereToBSplineSurface/Convert_CircleToBSplineCurve both genuinely inherit the base class each helper is typed on (verified against the bundled OCCT headers), so both are now drop-in calls to the shared helper. Same signature, same OCCT calls, same output for every input tested (exact-equality baseline captured from the pre-change implementation, plus a geometric-invariant check independent of the implementation), non-breaking.

Kernel pin moved to v2.0.0-kernel.2, carrying all fourteen patches (#512)

  • Pinned v2.0.0-kernel.2, which carries all fourteen patches in Scripts/patches/. The previous pin held eleven, leaving the ChFi2d_Builder::AddChamfer (#705), GeomTools null-handle (#643) and Extrema_ExtCC bounds (#636) fixes untested by any CI job. (#512)

detectPocketsAAG() no longer double-counts a single pocket’s floor; its floor/wall match survives meshing and is now caller-configurable (#724, #733)

A single blind cylindrical pocket reported two pockets instead of one:

let box  = Shape.box(origin: SIMD3(-10, -10, -10), width: 20, height: 20, depth: 20)!
let tool = Shape.cylinder(at: .zero, direction: SIMD3(0, 0, 1), radius: 4, height: 20)!
let cut  = box.subtracting(tool)!
print(cut.detectPocketsAAG().count)   // was 2, now 1

Ground truth for this solid, from OCCT’s own classifier ChFi3d::DefineConnectType: 13 convex edges, 1 concave (the bore’s floor meeting its cylindrical wall), 1 tangential (the cylinder’s own seam). One concave edge joining a wall to a floor is one pocket.

This was a grouping defect, not a classification one, and is independent of #703 (the face1/face2 order fix) and of #723 (the separate replacement of OCCTEdgeGetConvexity’s formula with ChFi3d::DefineConnectType): AAG.detectPockets() selected a floor candidate on isUpward && isHorizontal && isPlanar plus at least one concave, vertical neighbor, with no check that the neighbor was actually resting on that floor rather than merely touching it somewhere along its height. A curved wall’s rim, where it meets the exterior surface it opens through, can classify concave under the convexity formula even after #703’s fix, since that fix addressed a planar/planar order-dependence and left a planar/cylindrical pair’s classification unchanged. The box’s own top face satisfies the floor predicate exactly as well as the real floor does, and once its rim edge to the bore’s wall is (wrongly) concave, it is indistinguishable from a second, shallower floor sharing the same wall.

Fixed: a wall only counts toward a given floor candidate if the wall’s own bounding-box minimum Z matches that floor’s Z, within a tolerance (AAG.defaultFloorRestsOnWallTolerance, 1e-4) far tighter than any real pocket depth and far looser than bounding-box noise on an exact primitive. A pocket floor is upward-facing, so it is always the low end of the walls that rise from it; a wall’s high end is where it opens, whether to the exterior, to a shallower pocket’s floor, or to open air, never to a floor of its own. This holds regardless of whether the wall’s rim classified concave for a legitimate reason or a wrong one, so the fix does not depend on #723 and did not need revisiting when it landed.

Two further fixes, both from this PR’s own review:

  • The Z-match compared against Face.bounds, which BRepBndLib::Add enlarges by the mesh’s deflection once a shape has been meshed. Measured on the fixture above: at the mesh library’s own default deflection (0.1), a meshed shape’s wall bound drifted ~290x past the tolerance, and every deflection tried (0.001-5.0) exceeded it, dropping the pocket outright once the shape had been meshed at all before calling detectPocketsAAG(). A planar-walled pocket never drifted (a plane triangulates without curvature error), which is why the fix above’s own fixtures didn’t catch it. Fixed with a new internal Face.exactBounds (backed by a new bridge entry point, OCCTFaceGetBoundsExact, BRepBndLib::Add(..., useTriangulation: false)), used only inside AAG; Face.bounds itself is unchanged for every other caller.
  • The tolerance was a hardcoded private static let, the only non-configurable tolerance anywhere in this module (every other tolerance, 296 of them, is a caller-supplied parameter with a default). Promoted to a tolerance: parameter on both AAG.detectPockets(tolerance:) and Shape.detectPocketsAAG(tolerance:), defaulting to the same 1e-4 (AAG.defaultFloorRestsOnWallTolerance, now public). Existing call sites are unaffected.

Migration note: detectPocketsAAG()/AAG.detectPockets() can now report fewer pockets, and a PocketFeature.wallFaceIndices can now be shorter, for any shape where a wall’s rim classified concave without the wall actually bottoming out at that candidate floor. A genuine multi-wall pocket, where every wall shares the same floor Z, is unaffected: measured on the existing square-pocket fixtures (Issue703EdgeConvexityOrderTests, Shape.detectPocketsAAG()’s own doc example), the pocket count and each wall count are unchanged, meshed or not. One known limitation, not exercised by any fixture in this codebase: a filleted floor/wall junction would round the wall’s bounding box past the floor’s own Z by roughly the fillet radius, which could exceed the default tolerance; pass a larger tolerance: for that case.

Tests: Tests/OCCTModelingTests/Issue724PocketGroupingFloorTests.swift, Tests/OCCTModelingTests/Issue733MeshTriangulationBoundsTests.swift (both new).

Surface.joinBezierPatches rejects rational patches instead of silently dropping their weights (#725)

GeomConvert_CompBezierSurfacesToBSplineSurface has no rational path: its own precondition against rational input is a Standard_NotImplemented_Raise_if that this project’s Release kernel compiles out via No_Exception, so a rational Bezier patch used to convert to the polynomial surface through the same control net and report success. Measured on a rational quarter-cylinder patch: a 0.606602 radius error reported as IsDone() == true. joinBezierPatches now rejects any patch that is rational in either direction before constructing the converter, using the same IsURational() || IsVRational() predicate the compiled-out guard used. Weights are never clamped or silently dropped.

Shape.geomFillSweep rejects a fit that misses its own tolerance instead of reporting it as done (#597)

GeomFill_Sweep::Build’s general path always fits the swept surface with an internal approximation and records the achieved deviation in ErrorOnSurface(); IsDone() alone says only that some surface was produced, not that it met the tolerance the fit was built at. geomFillSweep used to accept whatever Build() returned without ever reading that number. Measured: a rapidly oscillating spine can report IsDone() == true with ErrorOnSurface() == 13.0, five orders of magnitude past the 1e-4 tolerance the fit was built at. Now rejects a fit whose reported error exceeds that tolerance. Two upstream kernel sites the wider issue also names (GeomFill_Sweep.cxx’s ForceApproxC1 branch, ShapeUpgrade_UnifySameDomain.cxx) are not reachable from this bridge file and remain open; see #597 for the measurement.

Surface.networkSurface now succeeds on well-formed curve networks (#689)

networkSurface used to fail every network tried, including the simplest possible 2x2 bilinear patch of straight lines, a network with nothing to approximate at all. The cause was not the intersection grid: it was that the locator parameters handed to the underlying GeomFill_NetworkSurface builder were a caller-invented [0,1] fraction rather than each curve’s own raw parameter in the other family’s domain, which the builder requires to match before it will align its internal profile, guide, and reference skins to one knot basis. Fixed by finding each profile/guide pair’s real contact point and parameter via GeomAPI_ExtremaCurveCurve (the same class GeomFill_Gordon’s own internal network preparation uses) and averaging across the family. networkSurface now builds well-formed networks, including a network of genuinely rational profile curves, matching their reference geometry to within floating-point precision; it does not replicate GeomFill_Gordon’s full curve-reordering and reparametrization pipeline, so a scrambled or genuinely misaligned network can still decline. This new call site of GeomAPI_ExtremaCurveCurve is guarded against the parallel-curve SIGSEGV #636 found in the same class, since that fix (PR #730) is scoped to a different file and does not cover it.

Review-response follow-up (same PR, second pass): the contact-point loop above picked GeomAPI_ExtremaCurveCurve’s solution index 1 unconditionally instead of its nearest extremum, and substituted a fabricated placeholder point (each curve’s own FirstParameter()) for a parallel/no-extremum pair, silently averaging it into an otherwise-real result. Both fixed: the nearest-extremum accessors are used instead of index 1, and a pair with no real contact now rejects the whole network as .invalidInput rather than fabricating one. Separately, verifying a docs claim about this same function surfaced a distinct, unrelated kernel defect in GeomFill_NetworkSurface itself (two of four corners of the built surface can be wrong even when .done is reported); filed as #748, not fixed in this PR.

Shape.divided(at:) and dividedByContinuity(criterion:tolerance:) unified onto one entry point (#438)

Shape exposed ShapeUpgrade_ShapeDivideContinuity through two public entry points that set different criteria on the same builder: divided(at:) set boundary, pcurve AND surface criteria together, matching OCCT’s own shape-healing guide; dividedByContinuity(criterion:tolerance:) set only the boundary criterion, leaving pcurve/surface pinned at the class’s own C1 default regardless of the requested continuity. Measured (Scripts/repro/cluster-d-continuity) as a flat result across every criterion on a fixture where divided(at:)’s own behaviour varies (nil/4/4/25 faces at C0/C1/C2/C3).

divided(at:) is now divided(at:tolerance:), taking ContinuityLevel (the strict superset dividedByContinuity alone used to reach) and a tolerance parameter (default 1e-7, matching this method’s prior implicit behavior). dividedByContinuity(criterion:tolerance:) is deprecated and forwards to it. One behaviour change for existing dividedByContinuity callers: .c0 now returns nil rather than a spurious non-nil result, matching divided(at:)’s own .c0 answer, because the pcurve/surface criteria are now genuinely applied instead of silently ignored.

Shape.vinertGK(...) reports a real integration error; VinertGKResult.absoluteError removed (#732)

OCCTBRepGPropVinertGK hardcoded result.errorReached to 0.0 on every call. The comment explaining it (“GetErrorReached is inline-only in OCCT 8.0.0”) had the conclusion backwards: an inline accessor is callable precisely because the compiler emits its body into the caller, which is why nm finds no exported symbol for it. Fixed by calling BRepGProp_VinertGK::GetErrorReached() directly; measured on a radius-10 sphere, it now tracks the requested tolerance across six orders of magnitude (8.09e-4 at 1e-3, down to 1.14e-9 at 1e-9) instead of reporting 0 at every one.

VinertGKResult.absoluteError is removed rather than given a second real value. BRepGProp_VinertGK does declare a second accessor, GetAbsolutError(), but it has no definition anywhere in the OCCT 8.0.1 sources: calling it fails at link time (symbol(s) not found, confirmed by compiling against it). Deriving one from errorReached * mass recovers the source’s internal absolute value in the common case, but not in OCCT’s own near-zero-mass branch, where the un-normalized value is kept as-is rather than divided, so that derivation would go quietly wrong exactly where a caller most needs a reliable number.

errorReached itself is also documented more precisely now: it is a relative fraction of mass only when |mass| clears an internal floor; below it, OCCT returns the undivided residual as-is instead, with nothing in the return value distinguishing which happened. See the doc comment on VinertGKResult and the review response below for the full investigation.

Curve3D.extrema(with:) no longer crashes the process on parallel curves (#636)

Found while building a fixture for #622: a parallel-curve fixture for extrema(with:) crashed the process outright, unrelated to the count bound #622 was about. CLAUDE.md’s Known OCCT Bugs already documents the topological sibling (BRepExtrema_ExtCC crashes on parallel edges, guarded with isParallel); OCCTCurve3DExtrema (OCCTBridge_Curve3D.mm), reached from the Swift API by a different bridge path through GeomAPI_ExtremaCurveCurve, had no equivalent guard.

Root cause, confirmed against the pinned V8_0_1 source before touching anything: GeomAPI_ExtremaCurveCurve wraps Extrema_ExtCC. On parallel curves, Extrema_ExtCC::PrepareParallelResult reports exactly one “extremum” by appending a distance to its private mySqDist sequence, but several of its branches (an unbounded pair, or a bounded pair whose projected ranges overlap, where a whole span is equidistant rather than one discrete closest point) never append the matching pair to mypoints. NbExtrema() reports 1 (mySqDist.Length()), so Points() indexes an empty NCollection_Sequence. This project’s OCCT is built with BUILD_RELEASE_DISABLE_EXCEPTIONS=ON (No_Exception), so the bounds check that would normally throw Standard_OutOfRange compiles to nothing: the out-of-range access is undefined behaviour, confirmed via a standalone binary linked directly against libOCCT-macos.a to be a genuine SIGSEGV, not a C++ exception. A catch (...) around the call cannot help: an OS signal never reaches it.

Measured against two fixtures, both crashing before the fix and both built from real Curve3D factory methods: two unbounded parallel Geom_Lines (Curve3D.line(through:direction:)), and two bounded parallel segments with overlapping projected ranges (Curve3D.segment(from:to:), the exact fixture shape Issue622AllocationBoundsTests deliberately avoided making parallel, per its own comment).

Fixed: OCCTCurve3DExtrema now queries IsParallel() and returns empty before touching any solution, mirroring the guard already in place for the sibling Extrema_ExtCC/Extrema_ExtCS entry points in the same file (OCCTExtremaExtCC/OCCTExtremaExtCS). OCCTCurve3DMinDistanceToCurve (Curve3D.minDistance(to:)), which shares the same GeomAPI_ExtremaCurveCurve construction, needed no change: it only calls LowerDistance(), which reads mySqDist, populated correctly in every parallel branch, and never touches the empty mypoints. Measured, not assumed: it already reported the correct offset distance for both parallel fixtures before this fix.

Audited the rest of the bridge for the same shape (every GeomAPI_ExtremaCurveCurve, Extrema_ExtCC/Extrema_ExtCS, and BRepExtrema_ExtCC call site): OCCTCurve3DExtrema was the only unguarded one. OCCTBridge_Geom2d.mm’s OCCTCurve2DAllExtrema (Geom2dAPI_ExtremaCurveCurve) has the identical unguarded .Points() call and is very likely the 2D sibling of this same defect, but that file is outside this PR’s scope; noted here as a follow-up rather than fixed.

Issue636ExtremaParallelCurvesTests (Tests/OCCTCurveTests), 4 tests. Since this is an uncatchable OS-signal crash, an in-process test cannot observe “before” without taking the whole test runner down with it: the defect and the fix were instead proven out-of-process first, via a temporary probe in Sources/OCCTTest/main.swift (restored byte-identical afterward) run as swift run OCCTTest. With the guard removed: SIGSEGV (exit 139). With the guard restored: clean exit reporting the expected empty result. The permanent, safe-to-run-in-process regression tests landed only once both were confirmed.

Bridge and Swift only: no kernel patch, no OCCT.xcframework rebuild.

Prepared the upstream kernel fix for Extrema_ExtCC::Points() on parallel curves (#636)

Extrema_ExtCC::NbExt() counts mySqDist; Extrema_ExtCC::Points() reads a different container, mypoints, but bounds-checked the request against NbExt(). Several branches of PrepareParallelResult leave a distance in mySqDist with no matching pair in mypoints (a genuinely parallel curve pair has no unique closest point, only a distance), so NbExt() reported 1 in exactly the cases Points(1) indexed an empty sequence, a SIGSEGV under this project’s No_Exception Release build, already mitigated bridge-side in PR #730. Scripts/patches/0024-* bounds Points() against mypoints.Length() instead, validated via override-link against the pinned kernel (not a full rebuild): the crash becomes a catchable Standard_OutOfRange, and the two non-crashing fixtures are byte-identical before and after. Includes a companion, behavior-neutral IsParallel() forwarder on Geom2dAPI_ExtremaCurveCurve, closing an ergonomic (not crash) gap between the 2D and 3D wrappers. Upstream issue/PR text drafted, not filed. See Scripts/repro/636-extrema-parallel/.

Verified the status of ten open upstream OCCT PRs and assessed patch 0020 for filing (#657)

Re-verified all ten upstream OCCT PRs behind our pre-8.0.1-base carried patches (0010-0012, 0014-0021) against the GitHub API rather than trusting recorded status: all ten remain open with green CI, none needs a conflict-resolving rebase (each patch still applies to current upstream master with zero rejected hunks), and the belief that OCCT#1399 (patch 0016) still needed its Storage_Schema fix updated to match maintainer review was already stale, that update shipped three days before it was recorded. Flagged OCCT#1417 and OCCT#1418 as carrying unanswered maintainer feedback. Confirmed carried patch 0020 (BRepFeat_MakeCylindricalHole part selection, #532), still unfiled upstream, is ready to submit: still applies, its reproducer runs and matches its documented post-fix measurements, and it needs no reformatting against OCCT’s own .clang-format. Drafted the upstream PR text; no upstream issue, PR, comment or push was made. See Scripts/repro/657-upstream-pr-status/.

detectPocketsAAG() now finds a pocket whose floor/wall junction is filleted or chamfered (#762)

A fillet at a pocket’s floor/wall junction is tangent to both surfaces (ChFi3d::DefineConnectType reports .smooth at both new edges by construction), so concaveNeighbors(of:) found nothing and the pocket was invisible. A chamfer’s two new edges classify .concave correctly, but the chamfer face is planar at an intermediate angle and fails the wall’s own isVertical filter, so the search never continued past it. Since nearly every real machined pocket has a filleted floor/wall junction, this was non-detection of the pockets anyone would actually cut, not an edge case.

Fixed by tracing outward from the floor through any absorbed junction face, a chamfer via its own correct .concave classification, a fillet via a new radially-inward curvature test, until a genuine wall is reached. Ground-truthed against ChFi3d::DefineConnectType directly first (Scripts/repro/762-filleted-pocket-detection/), including false-positive guards for a filleted through-slot (must stay open), a filleted boss (must never report a false enclosed pocket), and a plain box with its own exterior edges filleted (must report no pocket at all).

let box = Shape.box(width: 20, height: 20, depth: 20)!
let pocketTool = Shape.box(origin: SIMD3(-5, -5, 0), width: 10, height: 10, depth: 15)!
let cut = box.subtracting(pocketTool)!
let junctionEdges = cut.edges(where: { abs($0.bounds.min.z) < 1e-6 && abs($0.bounds.max.z) < 1e-6 })
let filleted = cut.filleted(edges: junctionEdges, radius: 1.0)!
print(filleted.detectPocketsAAG().count)   // 1, was 0

detectHoles() was checked against the same blindness and found unaffected (see above); no change made there.

Carried patch 0020 (#532, BRepFeat_MakeCylindricalHole tool part selection) is now filed upstream as OCCT#1447

Re-verified the defect directly against current upstream master before filing: the four affected call sites, the two correct callers in the same builder, and the second, unfixed defect in the same heuristic are all unchanged from the V8_0_0_p1-based measurement this patch was originally built against, and the touched file is byte-identical between master and our pin. Confirmed by compiling BRepFeat_MakeCylindricalHole.cxx from master as an override translation unit, once unmodified and once patched, and running the existing reproducer against both: every “before” and “after” figure in the original writeup reproduces exactly, including the oversized-radius status change. Filed as Open-Cascade-SAS/OCCT#1447, PR only per okf/policies/upstream-occt-style.md. No change to OCCTSwift’s own behavior or public API.

Dead BisectorPoint/OCCTBisectorPointOnBis bisector-point value type removed (#771)

BisectorPoint (Swift), and the OCCTBisectorPointOnBis bridge struct and OCCTBisectorPointOnBisCreate function it wrapped, are removed. Found while teaching Scripts/census-unmeasured-values.py a third sub-kind (a boolean gate flag assigned false somewhere and true nowhere): OCCTBisectorPointOnBisCreate never called into OCCT’s Bisector_PointOnBis and had no Swift call site, and BisectorPoint had no public initializer and no constructor call site anywhere in the package. Both sides were fully dead code, not a stuck gate on a live path. BisectorIntersection and bisectorIntersections(a:b:c:d:), the live bisector API in the same file, are unaffected.

// Before: an unconstructable public type (no public init, no factory anywhere)
// public struct BisectorPoint { ... } // removed

// After: use bisectorIntersections(a:b:c:d:) for bisector work; there is nothing to migrate a
// BisectorPoint value to, since nothing could ever have produced one.

Shape.analyze(tolerance:) can now check self-intersection (opt-in) (#772)

analyze(tolerance:selfIntersectionTimeout:) gains one new parameter, selfIntersectionTimeout: Double? = nil. nil (the default) skips the self-intersection check entirely; a non-nil value opts in, forwarded as the timeout: to isSelfIntersecting(timeout:) (the same BOPAlgo_ArgumentAnalyzer check isSelfIntersecting uses), and populates the new ShapeAnalysisResult.hasSelfIntersection: Bool? field: nil when not requested, or requested but indeterminate; true/false when the check resolved. totalProblems adds a flat +1 when hasSelfIntersection == true, matching how hasInvalidTopology is counted.

Passing a non-nil selfIntersectionTimeout makes this call block the calling thread for up to that many seconds (more, if OCCT never reaches a checkpoint to poll); do not pass it from a UI/main thread without accepting that stall.

Measured before deciding (Scripts/repro/772-analyze-self-intersection/): the check costs 1x-2x the rest of analyze()’s scan on ordinary shapes but ~3500x-4200x on a known pathological artifact (a few ms vs 30+ seconds), so it defaults off rather than running unconditionally.

// Default stays cheap; self-intersection is not reported unless asked for.
let analysis = shape.analyze(tolerance: 0.001)
print(analysis?.hasSelfIntersection)   // nil

// Opt into the expensive, thread-blocking check when it's actually needed.
let checked = shape.analyze(tolerance: 0.001, selfIntersectionTimeout: 30)
switch checked?.hasSelfIntersection {
case .some(true):  print("self-intersects")
case .some(false): print("clean")
case nil:          print("indeterminate or not requested")
}

ShapeAnalysisResult.selfIntersectionCount removed; ShapeAxis.extent now computed for revolutionAxes()/symmetryAxes() (#763)

ShapeAnalysisResult.selfIntersectionCount is removed. It was always 0, never computed (the bridge’s own comment admitted “would require more expensive computation”). Use Shape.isSelfIntersecting(timeout:) for a real self-intersection check.

ShapeAxis.extent (from Shape.revolutionAxes(tolerance:) and Shape.symmetryAxes(fractionalTolerance:)) is now genuinely computed rather than always nil: it reports the axis’s own shape’s bounding box projected onto the axis direction, in real 3D units. Face.primaryAxis is unaffected and still never populates extent.

// Before: always nil
let axis = Shape.cylinder(radius: 5, height: 20)!.revolutionAxes().first
axis?.extent  // nil, always, regardless of the cylinder's actual height

// After: a real measured extent
axis?.extent  // Optional(0.0...20.0), or -20.0...0.0 since the axis direction sign varies
  • Pinned v2.0.0-kernel.3, adding patch 0025 (GeomFill_Sweep conversion error, #597) and the revised 0018 (GCPnts, #555) after upstream review. (#512)

Document.shapeColor/setShapeColor now round-trip alpha correctly (#763)

Document.shapeColor(_:type:) previously always reported alpha == 1.0 regardless of what was actually stored, because the bridge read a shape’s color through XCAFDoc_ColorTool’s RGB-only GetColor overload. It now reads the RGBA overload and reports the real value, for example a STEP import with a transparent surface style. Document.setShapeColor(_:color:type:) previously silently dropped color.alpha on write; it is now stored and round-trips through shapeColor.

AAG.detectHoles() no longer reports zero holes for an ordinary blind or through cylindrical hole (#747)

detectHoles() required every neighbor of a candidate face to connect via a concave edge, a criterion written against the convexity formula #723 replaced, and unsatisfiable under the correct one for both a through-hole (zero concave neighbors) and a blind hole (one out of two). Replaced with a criterion built from the wall’s own geometry: cylindrical or conical, closed in U by its own seam, and with material lying radially outside the wall rather than inside it, which also correctly excludes a boss or standalone cylinder (topologically identical to a hole in every way neighbor convexity can see) and correctly finds a hole bored on any axis, not only a vertical one.

let box  = Shape.box(origin: SIMD3(-10, -10, -10), width: 20, height: 20, depth: 20)!
let tool = Shape.cylinder(at: .zero, direction: SIMD3(0, 0, 1), radius: 4, height: 20)!
let cut  = box.subtracting(tool)!
print(cut.buildAAG().detectHoles().count)   // 1, was 0

PocketFeature.isOpen tests enclosure per edge, not by wall count or by summing shared-edge counts (#735)

isOpen was wallIndices.count < 3, a proxy that does not mean enclosure: a blind cylindrical pocket has exactly one wall and is fully enclosed, so it reported isOpen == true, and a genuinely open three-sided slot and a closed three-walled pocket both have exactly 3 walls and were indistinguishable by counting alone.

Fixed to test enclosure directly and per edge: for each edge of the floor’s own outer wire, ask which faces border it in the shape and check whether one of them is a member of wallFaceIndices, by structural identity rather than by summing a shared-edge count. This closes two review findings on an earlier version of the fix that compared counts instead: a boss standing on the pocket floor could mask a genuine gap in the outer wall loop (the count included the boss’s own inner-wire edge alongside the outer boundary’s), and the underlying shared-edge count is separately capped by a fixed-size bridge buffer. Neither can occur with the per-edge test, since it never sums a total and never visits an inner-wire edge.

GeomFill_Sweep reports the achieved conversion error, not the tolerance that was requested (#597)

Carried kernel patch 0025. When BRepOffsetAPI_MakePipeShell and its callers force C1 continuity and the swept surface is not already C1 in V, GeomFill_Sweep::BuildAll re-approximates through GeomConvert_ApproxSurface and then overwrote the measured surface error with the tolerance the caller asked for. Every consumer of ErrorOnSurface() on that path was therefore reading its own request back. Measured on a helical sweep: 0.0001 reported against a real 2.54714.

Diagnostic only. The returned geometry is byte-identical before and after, confirmed by override-linking the patched translation unit against the pinned archive, and no bridge site gates on the value today.

kernel-integration.yml no longer discards a successful 79-minute build on timeout (#727)

The workflow’s trigger paths (Scripts/patches/**, Scripts/build-occt.sh) and its xcframework cache key (hashFiles of those same two globs) were the same condition, so every run the workflow exists to do was, by construction, a first-time cache miss: there was no run where the ~79-minute build could be skipped. With build and swift test as one job, actions/cache’s save runs as an automatic post step queued for the end of the job; a job cancelled by its own timeout-minutes partway through swift test never reaches that post step, so it reports skipped, not cancelled, discarding a build that had already finished successfully one step earlier. Measured on PR #718’s job 92538270414: 79 min to build OCCT (success), 10 min into swift test before the 90-minute job timeout fired (cancelled), cache save skipped. Retrying reruns the same build and times out at the same place, so there was no path to green by re-running.

Split into two jobs: build-kernel builds and caches Libraries/OCCT.xcframework (timeout-minutes: 120, roughly 50% margin over the measured 79 min), and swift-test (needs: build-kernel, timeout-minutes: 30) restores that cache and runs swift test against it. The build job now has nothing left to run after the build, so its cache save is no longer hostage to the test job’s runtime, and each job’s timeout matches what it actually does instead of both sharing one 90-minute budget. The xcframework reaches the second job only through actions/cache, never actions/upload-artifact: the two jobs compute the same cache key independently (hashFiles of the same commit each checks out), rather than passing it as a job output.

The build job’s own cache handling also moved from the combined actions/cache action to explicit actions/cache/restore plus actions/cache/save (the latter if: always()), placed immediately after the build step instead of left as an automatic post step. Splitting the job already closes the main gap; this closes a narrower, related one the split does not: this workflow’s own concurrency.cancel-in-progress cancels the whole in-progress run, both jobs, if a new commit lands on the same ref while the build is still going, and an if: always() step still gets a chance to run after that cancellation signal, unlike a not-yet-started automatic post step.

Verified: the two-job workflow parses and its workflow_dispatch trigger is reachable. Not verified in this change: a real cache-miss run start to finish, the ~80-minute cost this fix exists to stop wasting, had not completed by the time the PR was opened. See the PR description for exactly what ran and what remained outstanding.

Surface.appSurf(curves:) rejects fewer than 2 curves instead of crashing; two sibling GeomFill_* null-handle guards (#644, #710)

Two independent, uncatchable SIGSEGVs in OCCTGeomFillAppSurf (OCCTBridge_Surface.mm) and its two sibling bridge functions, both migration notes since neither can be a docs/SEMVER.md recorded exception – that mechanism is for breaks within a major line, and both land in the still-unreleased v2.0.0.

#644: Surface.appSurf(curves:) SIGSEGVed on 0 or 1 curves; 2+ already returned isDone: true cleanly. Not the unguarded (double)i / (double)(count - 1) divisor the issue’s own investigation had already disproved – substituting a guarded 0.0 there still crashed. The real mechanism: GeomFill_AppSurf’s approximation solver (AppDef_Compute, reached through AppBlend_AppSurf::InternalPerform) is never driven with fewer than 2 sections anywhere in the kernel; at 1 section the first and last constraint point are the same section evaluated twice, and the solver’s degree-of-freedom bookkeeping – built assuming at least one free interior span – segfaults. Measured via a forked ground-truth probe against the pinned kernel and, separately, a temporarily-repointed Sources/OCCTTest/main.swift run as a standalone process (an in-process @Test cannot assert a SIGSEGV without killing the whole suite): counts of 0 and 1 both SIGSEGV (exit 139), 2 and 3 both return cleanly (exit 0), before and after.

Surface.appSurf(curves:) was the one function in this family missing the guard, not a new gap: Surface.nSections(curves:params:) and Surface.generatedFromSections(curves:tolerance:) already carry a curves.count >= 2 guard at the same Swift boundary. appSurf now matches them exactly: guard curves.count >= 2 else { return nil }.

#710: found building #666’s Cluster C census, OCCTBridge_Surface.mm has nine sites reaching a Handle(Geom_Curve) through *(const Handle(Geom_Curve)*)curveRef – a fifth alias form Scripts/check-null-handle-guards.py cannot see even after #711 taught it the #656 shape. Six wrap the result immediately in GeomAdaptor_Curve, which raises a catchable Standard_Failure on a null Handle, already absorbed by each function’s own catch (...). Three do not, and each SIGSEGVs uncatchably on a null Handle(Geom_Curve):

  • OCCTGeomFillProfilerAddCurve (GeomFill_Profiler::AddCurve – CurveProfiler.addCurve(_:))
  • OCCTGeomFillAppSurf’s own curveRefs loop (the same non-virtual GeomFill_Profiler::AddCurve, inherited by GeomFill_SectionGenerator)
  • OCCTGeomFillSectionPlacement’s sectionCurve argument (GeomFill_SectionPlacement ctor – its pathCurve argument is already safe, wrapped in GeomAdaptor_Curve first)

Each gets if (<curve>.IsNull()) return <fallback>; immediately after binding the alias. The three guarded aliases (curveRef’s curve in OCCTGeomFillProfilerAddCurve, curveRefs[i]’s curve in OCCTGeomFillAppSurf’s loop, sectionCurveRef’s sectionCurve in OCCTGeomFillSectionPlacement) are bound through ->curve field access rather than the file’s *(const Handle(Geom_Curve)*)ref cast form, so check-null-handle-guards.py’s already-recognised “handle alias” pattern sees them: removing any one of the three guards is now a CI failure, confirmed by injection: remove, checker reports the site, restore, checker is clean again (for all three). pathCurve in OCCTGeomFillSectionPlacement and the six pre-existing GeomAdaptor_Curve-wrapped sites keep the cast form, unchanged: pathCurve needs no guard (GeomAdaptor_Curve copes), and ALLOWED has no per-argument granularity, so exempting OCCTGeomFillSectionPlacement by name to cover pathCurve would also blind the checker to the sectionCurve guard this fix depends on. Teaching the checker the cast form itself as a general fifth alias shape remains the separate, deferred follow-up work described below and in the repro README; this is a narrower, in-place idiom swap at exactly the three sites this fix already touches, not that census.

Reachability, measured rather than assumed. #710’s own filing said CurveProfiler “has … no public factory anywhere,” concluding its guard was latent. That premise was wrong: CurveProfiler.create() is public and has been since the type was first wrapped (already exercised by GeomFillProfilerTests) – the “inaccessible” compiler error the filing hit was from calling the internal init(handle:) directly, the same internal/public-factory split every geometry wrapper in this codebase uses. All three sites are equally reachable in that respect.

The harder question – can any public factory produce a Curve3D wrapping a null Handle(Geom_Curve) to drive the crashing input through – was answered by reading every OCCTCurve3D-constructing site across all five bridge files that produce one (~100 sites): every one checks IsNull()/IsDone() first, constructs from a fresh concrete Geom_* object that cannot be null, or copies an already-checked input. No public factory produces one, reconfirming #478’s sweep of the (then) 228 handle-binding sites. All three fixes are therefore defensive hardening against a hazard the current tree cannot trigger through the public Swift API today, not a live crash reachable right now – worth doing anyway, since the guard is a one-line match to established idiom and the invariant is a fact about today’s sites, not a guarantee about the next one. See Scripts/repro/644-710-geomfill-appsurf-null-arity/README.md for the full measurement, including why teaching the checker this alias form is real but separate follow-up work, not part of this fix.

Automated review pass on PR #722. Three of four findings held up and are folded into the fix above (the checker-visibility idiom swap is described there); the fourth was verified and rejected with evidence rather than implemented:

  • CurveProfiler.addCurve(_:)’s null-handle drop had no doc comment, unlike sectionPlacement’s sibling entry updated in the same original commit. Added to both the /// comment (CurveProfiler.swift) and docs/reference/Shape-Completions.md, describing the only indirect signal available: a dropped curve shifts every later curveIndex passed to poles(curveIndex:) by one.
  • Tests/OCCTSurfaceTests/OCCTSurfaceTests.swift’s pre-existing approximateSurface (in GeomFillAppSurfTests) asserted inside a nested if let, silently skipping on nil: the same blind pattern the new regression tests in this fix deliberately avoid two tests below it. Converted to the same guard ... else { Issue.record(...); return } idiom. Proven by injection: temporarily made appSurf(curves:) always return nil, confirmed approximateSurface now fails (it previously would have passed silently), restored, confirmed green again.
  • Rejected: that appSurf’s curves.count >= 2 guard exists only in Swift and the bridge function itself SIGSEGVs if reached directly with count < 2. True, but not a gap this PR introduced: OCCTGeomFillNSections and OCCTGeomFillGenerator, the two sibling bridge functions backing Surface.nSections(curves:params:) and Surface.generatedFromSections, have the identical gap and predate this PR untouched. Adding a bridge-side arity guard to only OCCTGeomFillAppSurf would make it inconsistent with both siblings, not more correct; the Swift-boundary guard is this family’s established, deliberate convention (OCCTBridge is an internal target, not a public product; see #486). The review’s separate observation that no automated gate covers arity the way check-null-handle-guards.py covers null handles is accurate and is left as a genuine, but separate, future census.

OCCTEdgeGetConvexity replaces its hand-rolled formula with OCCT’s own classifier (#723)

#703 (below) fixed the face1/face2 argument-order dependence by replacing a scalar triple product with a formula built from each face’s own area centroid, a GLOBAL property of the face, standing in for a LOCAL one (which side of an edge is material). That residual was filed as #723: the two averaged centroid terms are each a different global measure, so their sum drifts with face proportions. Measured: a round through-hole’s rim classified 2 concave edges at plate thickness 20 but 0 (correctly, by accident) at 40/60/120, because the cylindrical wall’s own centroid moves as the wall gets taller while the rim geometry itself never changes, the same failure class #703 fixed (a geometric answer depending on something that is not the local geometry), re-parameterised.

Fixed by replacing the formula entirely with ChFi3d::DefineConnectType, the classifier the 3D fillet and chamfer builders use themselves to decide which edges they can round or bevel, called with CorrectPoint=true, matching ChFi3d_Builder_1.cxx’s own call. It samples the LOCAL dihedral at the edge midpoint (each face’s normal there, from its pcurve’s D1, against the edge tangent), needs no per-face integration, and reports the identical classification for (face1, face2) and (face2, face1) by construction, so #703’s order-independence requirement holds with no centroid argument needed. ChFiDS_Concave/ChFiDS_Convex map onto the existing OCCTEdgeConvexity enum directly; ChFiDS_Tangential (and the residual ChFiDS_Other/ChFiDS_Mixed/ChFiDS_FreeBound cases this classifier can decline to resolve) map to Smooth, the same “can’t classify this edge” fallback the function has always used. SinTol reuses the previous formula’s smoothThreshold (0.01, ~0.5°) rather than introducing a new tolerance.

let plate = Shape.box(width: 50, height: 50, depth: 20)!
let drill = Shape.cylinder(at: SIMD3(0, 0, -15), direction: SIMD3(0, 0, 1), radius: 10, height: 30)!
let drilled = plate.subtracting(drill)!
print(drilled.buildAAG().edges.filter { $0.convexity == .concave }.count)   // was 2, now 0

Migration note: a hole rim (the edge where a cylindrical/conical wall meets the face it penetrates) now consistently reports .convex, never .concave, independent of wall height/thickness: a hole rim is convex by construction, since the solid occupies the quarter-space below the pierced face and outside the wall, a 90° material angle, not the 270° that makes an edge concave. detectPocketsAAG() no longer reports a through-hole as a 1-wall pocket at any thickness. This ships in v2.0.0, a major version, so it is not recorded as a SEMVER.md exception, since that mechanism is for breaks shipping within a major line.

Dead plumbing removed. #720’s review of #703 added OCCTFaceGetAreaCentroid (a cached BRepGProp::SurfaceProperties centroid, adaptive integration) plus a widened OCCTEdgeGetConvexity signature taking each face’s precomputed centroid, so AAG.buildGraph() could avoid repeating a whole-face integration per adjacent pair. ChFi3d::DefineConnectType needs no centroid at all, so all of that becomes dead: OCCTEdgeGetConvexity drops back to its original four-argument signature (shape, edge, face1, face2), OCCTFaceGetAreaCentroid is removed outright (confirmed to have no other caller before deleting it), and buildGraph()’s centroid-precompute loop is gone.

Performance improves further, it does not regress. #720 measured a 7-12x slowdown from OCCTEdgeGetConvexity recomputing BRepGProp::SurfaceProperties per face-pair, then recovered 2.06x by caching each face’s centroid once per occurrence instead. ChFi3d::DefineConnectType needs no integration at all, just a handful of D1 derivative evaluations per edge, the same order of cost as the pre-#703 tangent-plane formula. Re-measured with the same method (Shape.buildAAG() on a many-faced drilled-plate fixture, three runs per configuration): every post-#723 run was faster than every pre-#723 (cached-centroid) run, by more than 3x at the extremes (min 110ms vs min 354ms on a 262-face/524-pair fixture). See Scripts/repro/703-edge-convexity-order/README.md’s “Update following #723’s fix” for the full numbers and a caveat about a stale-incremental-build artifact hit while measuring.

Fixture bug found and fixed alongside the classifier swap. The through-hole regression test added under #703 pinned its drill’s base at a fixed Z (-5) rather than scaling it with plate thickness, so for every thickness the test actually exercised (20/40/60/120mm) the drill never reached the plate’s own bottom face (Shape.box(width:height:depth:) centers the box, so the plate’s bottom is at -thickness/2, below -5 once thickness > 10), so the fixture was silently a BLIND pocket with a floor fixed at Z=-5 for every tested thickness, not a through-hole at all, independently confirmed via ChFi3d (1 concave edge, matching a blind round pocket exactly, not the 0 a through-hole reports). Fixed by anchoring the drill’s base thickness/2 + 5 below center, so it clears the plate by 5mm on both faces regardless of thickness, matching the fixture’s evident intent.

AAG (Sources/OCCTSwift/FeatureRecognition.swift) is the only Swift caller of OCCTEdgeGetConvexity, re-audited for this fix rather than trusted from #703’s own audit.

Tests: Issue703EdgeConvexityOrderTests’s through-hole case (throughHoleConcaveCountIsPinnedPendingIssue723, an explicitly-labelled characterisation test pinning the old formula’s wrong answer of 2) is rewritten as a real ground-truth test (throughHoleHasNoConcaveEdges, pinning the correct answer of 0), with its fixture bug fixed in the same change. Proved by injection per okf/policies/prove-the-test-fails.md: the rewritten test, run against the pre-#723 formula, fails at three of its four parametrized thicknesses (2 concave edges reported, and a false 1-pocket count, at 20/40/60mm); restoring the ChFi3d fix makes all four pass. The suite’s other tests (plain box, glued boxes, genuine pocket, square pocket) are unaffected, since ChFi3d agrees with the centroid formula everywhere except the through-hole rim.

OCCTEdgeGetConvexity no longer depends on which face is passed first (#703)

Found via Cluster A’s census (#664, Scripts/repro/cluster-a-subshape-enumeration/) while confirming #699’s own fix, then confirmed directly on the simplest possible input: detectPocketsAAG() reported a pocket on a plain, uncut, convex 10mm box.

let box = Shape.box(width: 10, height: 10, depth: 10)!
print(box.detectPocketsAAG().count)   // was 1, now 0

A convex solid has no concave edges and therefore no pockets. OCCTEdgeGetConvexity’s formula, the sign of (tangent × n1) · n2, is a scalar triple product of the edge tangent and the two face normals: swapping which normal is n1 and which is n2 swaps two of its three terms, which negates a triple product. AAG.buildGraph()’s pairwise loop picks face1/face2 by array position (faces[i], faces[j], i < j), not by any geometric convention, so the same physical edge reported opposite convexity depending on face enumeration order rather than on geometry. Two of a plain box’s four side-wall-to-top-face dihedrals were misclassified concave this way, feeding a false pocket. The same mechanism, once per box, explains the 1/2 counts Issue642AAGNodeIdentityTests and Issue699AAGSolidScopedAdjacencyTests used to pin on their split-box fixtures.

Fixed: convexity is a property of an edge with respect to the solid it bounds, not of which face a caller happens to pass first, so the new formula is built to be symmetric under the face1/face2 swap rather than merely observed to be on the fixtures it was checked against. OCCTEdgeGetConvexity now takes each face’s own area centroid (BRepGProp::SurfaceProperties), a point well inside the face and away from the edge, and checks which side of the OTHER face’s tangent plane (at the edge midpoint) that centroid falls on, using that face’s own outward normal: a negative dot product means the two faces wrap around the solid’s material at this edge (convex), a positive one means the material recedes (concave). Doing this both ways, face1’s centroid against face2’s normal and face2’s centroid against face1’s normal, then averaging, makes swapping which face is face1 relabel the same two terms of the sum rather than change it.

Migration note: detectPocketsAAG(), AAG.concaveNeighbors(of:)/convexNeighbors(of:), and any AAGEdge.convexity a caller reads directly can all report a different, now-correct, answer for a shape that previously hit this defect. Measured moves: a plain box’s detectPocketsAAG().count goes from 1 to 0; the split-box fixtures in Issue642AAGNodeIdentityTests and Issue699AAGSolidScopedAdjacencyTests move from 1/1 to 0/0 in both compound member orders (both fixtures are two plain boxes glued face to face, which have no concave edges to begin with); a compound of two disjoint boxes plus a free face moves from 2 to 0. A genuine pocket (real overlap between a box and its cutting tool) is unaffected in kind: detectPocketsAAG() still finds it and still reports concave edges at the floor/wall junction: the fix corrects the sign convention, it does not flatten every edge to convex. This ships in v2.0.0, a major version, so it is not recorded as a SEMVER.md exception, since that mechanism is for breaks shipping within a major line.

AAG (Sources/OCCTSwift/FeatureRecognition.swift) is the only Swift caller of OCCTEdgeGetConvexity, re-audited for this fix rather than trusted from #701’s own earlier audit, so the blast radius is exactly the AAG-derived API surface above and nothing else.

Two long-standing fixtures needed correcting alongside the bridge fix, not because of it: the detectPocketsAAG() doc example in FeatureRecognition.swift and the detectPocket() test in Tests/OCCTModelingTests/OCCTModelingTests.swift both placed their pocket-cutting tool at origin: SIMD3(5, 5, 10) against a box centred at the origin (Shape.box(width:height:depth:) spans -10…10 on a 20mm axis), so the tool’s z range (10…25) only ever touched the box’s top face at z=10 with zero volume in common (measured: result.volume == box.volume, unchanged). The single “pocket” both used to report was entirely this same order-dependence, not a real feature of either shape. Both now place the tool at origin: SIMD3(-5, -5, 0), which actually overlaps the box and cuts a real 10mm-deep pocket.

Tests: Tests/OCCTModelingTests/Issue703EdgeConvexityOrderTests.swift (new), plus updated pins in Issue642AAGNodeIdentityTests and Issue699AAGSolidScopedAdjacencyTests. swift run Censuses cluster-a’s two detectPocketsAAG().count rows move from 1/1/1 to 0/0/0; the census and its README (Scripts/repro/cluster-a-subshape-enumeration/README.md) are updated to match. This is Cluster A’s last open member (#664).

Follow-up from PR #720’s automated review: three real, independently-measured issues in the fix above, addressed without touching the classification formula itself (a formula change is #723’s scope, deliberately deferred):

  • Performance (finding 7, confirmed). OCCTEdgeGetConvexity called BRepGProp::SurfaceProperties on face1/face2 directly, so AAG.buildGraph()’s pairwise loop repeated a face’s own whole-face integration once per neighbor. Measured 7-12x slower building the AAG of a 134-face part than the pre-#703 formula (which does no integration at all). Fixed: a new OCCTFaceGetAreaCentroid bridge call lets buildGraph() compute each face occurrence’s centroid once, before the pairwise loop, and pass it into OCCTEdgeGetConvexity (which no longer computes it internally), cutting the redundant-integration half of the regression exactly in half (measured 2.06x, matching the 2.09x the call-count reduction predicts). The remaining gap versus the pre-#703 baseline is the inherent cost of the centroid approach itself, not a caching problem; see Scripts/repro/703-edge-convexity-order/ for the full measurement.
  • Sliver-face guard (findings 2 and 9, not reproduced, added defensively). A dedicated probe (Scripts/repro/703-edge-convexity-order/repro_sliver.mm) fusing two boxes with a deliberate sliver overlap found no instability: OCCT’s own boolean tolerance handling does not let a genuinely near-zero-area face survive as its own topological face. OCCTFaceGetAreaCentroid still declines any face under area 1e-9 (several orders below the smallest sliver measured) and reports “no centroid”, which OCCTEdgeGetConvexity treats as Smooth: free, from the finding-7 restructuring, not because a live bug was found.
  • Integration accuracy (finding 8, confirmed as a real gap, closed). The plain SurfaceProperties overload has no bounded relative error; OCCTFaceGetAreaCentroid now uses the adaptive-integration overload (Eps = 1e-6, matching Face.area()’s own default), which does.

Two findings were investigated and are not regressions:

  • Finding 3 (removing the tangent-magnitude cusp guard). The new formula’s terms are the edge midpoint’s position, the two face normals, and the two face centroids, none of which derive from the edge curve’s own parametric derivative. A cusp in the curve’s parameterization (a vanishing derivative) cannot perturb an answer with no dependence on that derivative; the removed code was dead relative to the new method, not a guard the new method still needed.
  • Findings 1 and 4 (the centroid-toward-material assumption failing for holes/curved faces, and smoothThreshold no longer bounding a real angle). Both restate #723, already filed and tracked separately with its own measurement (a through-hole rim’s classification drifting with plate thickness). Every fixture in the PR’s own verification comment shows this PR matching or beating the base branch, never regressing it, including on holed/curved geometry; see Issue703EdgeConvexityOrderTests.throughHoleHasNoConcaveEdges (renamed and rewritten as a ground truth test when #723 landed; see that entry above) / squarePocketHasExactlyEightConcaveEdges (new, finding 6) for regression locks on curved/holed geometry that do NOT depend on #723’s disputed cases.

Finding 5 ((result.volume ?? 0) < (box.volume ?? 0) masking a nil volume computation as 0) is a test-quality fix, not a formula question: both occurrences (OCCTModelingTests.detectPocket() and Issue703EdgeConvexityOrderTests.genuinePocketStillReportsConcaveEdges) now unwrap both volumes and fail loudly if either is nil, matching this suite’s own guard let ..., let v0 = shape.volume else { #expect(Bool(false), ...) } idiom.

plateSurface’s point constraints reject .g2 in Swift instead of relying on OCCT’s own throw (#437)

Cluster D’s continuity census (#513/#667, Scripts/repro/cluster-d-continuity/) measured #437 as a genuine instance of the cluster’s shared root: SurfaceContinuity’s raw value is forwarded as a literal GeomPlate_PointConstraint/GeomPlate_CurveConstraint order, with no GeomAbs_Shape decode step at all. GeomPlate_PointConstraint’s point constructor throws above order 1 (pinned V8_0_1, GeomPlate_PointConstraint.cxx): a bare point carries no curvature to match, so .g2 was always out of domain for a point constraint. GeomPlate_CurveConstraint has no such restriction and accepts order 2 directly, so this is a point-only defect.

Shape.plateSurface(through:orders:...) and the point half of Shape.plateSurface(pointConstraints:curveConstraints:...) clamped the incoming order into [0, 2] and passed it straight to the constructor, which threw, unwound past the whole constraint loop, and was swallowed by the bridge’s blanket catch (...), returning nil with no indication which constraint was at fault.

Rejected at the Swift boundary instead of clamped or split into a new type. Three options were on the table (SurfaceContinuity.g2 documented as unsupported here already, from #436):

  • Clamp to [0, 1] in the bridge. Silently substitutes a lower order than the caller asked for, the same class of behaviour #430/#432/#434 deliberately moved away from (“used or the constraint fails, never silently substituted”).
  • Give point constraints a narrower type that cannot express curvature. Most precise, but adds a fourth continuity vocabulary right where #398/#490 spent real effort collapsing nine into two, and unlike #619 (which retired a parameter because its name lied about what it took), this would be introducing a new type for a shared enum that is correct everywhere else it is used (Shape.fill, FillingSurface, curve constraints all accept .g2 legitimately).
  • Reject in Swift, before building any constraint. Chosen. Keeps the single SurfaceContinuity vocabulary, and makes the nil a deliberate, documented decision instead of an accidental side effect of OCCT happening to throw where our bridge happens to catch.

The public answer does not change: a point .g2 was already nil and stays nil. What changes is that the rejection is now asserted in Swift (SurfaceContinuity.isUnsupportedForPointConstraint, Shape.plateMixedRejectsPointOrders) rather than incidentally produced by relying on OCCT’s internal domain check, and it no longer builds any GeomPlate_PointConstraints before failing. Issue437PlatePointG2Tests has the guard-removal matrix proving which of its own cases exercise the new mechanism and which pin the (unchanged) public contract only, per okf/policies/prove-the-test-fails.md.

Not fixed here: #438, a different, API-duplication-shaped defect in the same file that the census’s own measurement confirmed is unrelated to this one (two public APIs correctly decoding the same continuity through the same canonical decoder, but setting different criteria on the builder).

derive-bridge-header-split.py now verifies each declaration is in the header its .mm owns (#673)

--verify only ever asked whether every declared bridge function maps to exactly one .mm file, which was the whole question before #395 split the header. After that split there is a second question it did not ask: is the declaration actually in the header its .mm owns? A declaration could sit in OCCTBridge_Surface.h while its implementation lived in OCCTBridge_Modeling.mm and --verify would still report clean, because the symbol still maps to exactly one .mm.

The script now tracks declarations per header file, not just concatenated across all sixteen, so it can answer both. A hand check at the time #673 was filed found 0 misfiled of 4022, with 29 apparent duplicates all traced to mentions inside the umbrella’s cross-reference index comment block. Re-measuring rather than trusting that number: 0 misfiled of 4018 today (the drop from 4022 is unrelated churn, not a regression). Comments have to be excluded from the per-header scan for the same reason the hand check found 29 false duplicates: // Class -> OCCTFoo (aside) names a symbol followed immediately by (, the same shape a real declaration has, so an unstripped scan credits whichever header holds the comment with a second declaration of a symbol that is for real declared somewhere else.

Gained a --self-test, the one thing this script’s three siblings already had and it did not. Six cases, each proven by removing the guard it covers and watching the count drop, not merely added and trusted:

case guard removed result
clean split: nothing flagged dropped - {expected} from the wrong-header set 3/6
ambiguous: two .mm files define the same symbol disabled the ambiguous branch 5/6
unmapped: header declares a symbol no .mm defines disabled the unmapped branch 5/6
misfiled: declared in B.h, defined in A.mm disabled misfiled detection outright 5/6
line-comment mention elsewhere is not a misfile dropped // stripping 5/6
block-comment mention elsewhere is not a misfile dropped /* */ stripping 5/6

Each restored to 6/6 after its own row. The “clean split” row also catches the two comment-guard rows collapsing at once, since all three share the same wrong = home - {expected} computation; the other four guards are each isolated to exactly one row.

Joins ci.yml’s gate-scripts job as the fifth script, run with --verify (its bare form only prints the manifest and always exits 0). Unlike the other two derive-*.py scripts in Scripts/, which are one-time censuses for a split not yet done, this one protects an invariant from a split already shipped (#395) that every ~100-operation release can still violate by hand. The pre-commit hook (Scripts/git-hooks/pre-commit) gains the matching two invocations to stay flag-for-flag identical to CI, and CLAUDE.md’s Static Gate Scripts section, ci.yml’s own header comment, and the hook’s header comment are updated from four/seven to five/nine accordingly. In the same pass, ci.yml’s comment for count-operations.py is corrected: it said the script “ignores an unrecognised” option, which was true only until the same-day commit that made it exit 2 instead (confirmed still correct: count-operations.py --self-test and --bogus both exit 2 with a usage message).

Shape.analyze(tolerance:) reported zero free edges for every shape, and a healed()/fixSolid() demotion had no reliable signal (#702)

Shape.healed() and Shape.fixSolid() can reach isValid == true by demoting a solid to a shell: ShapeFix_Solid (via ShapeFix_Shape’s delegation, for healed()) hands back the shell unpromoted whenever it cannot close it, already correctly documented for fixSolid() since #442. A shell has no closure requirement of its own, so the demoted result is genuinely isValid, and the issue asked for a way to tell.

The issue’s own reproducer does not reproduce on this branch. It measured a ThruSectionsBuilder(isSolid: true, isRuled: false) loft through a 36-tooth bevel gear’s six section wires (1152 points each) on OCCT v1.17.0. Three independent reconstructions against this kernel (8.0.1 + patches), matching gear-tooth polygons at various tooth counts/tapers/twists, a dense sinusoidal profile, and a faithful port of OCCTSwiftScripts/recipes/04-spur-gear’s involute math scaled per section (matching the issue’s own 1152-points-per-wire figure exactly at flankSamples = 14, plus a spiral-twist variant), all produced a BRepCheck_Analyzer-valid raw loft, across 400+ parameter combinations. BRepCheck_Analyzer itself does not flag 3D self-intersection between non-adjacent faces either: a deliberately self-intersecting 179-degree-twisted star-prism loft still reports isValid == true, confirmed directly and matching analyze()’s own selfIntersectionCount, which is hardcoded to 0 and always has been (see below). Source-reading ShapeFix_Solid::Perform()/CreateSolids()/CollectSolids() found no path that demotes a genuinely closed shell either: every demotion in that class is gated on BRep_Tool::IsClosed/ShapeAnalysis_FreeBounds finding real free edges, matching #442’s documented contract. No fix was made for this specific scenario because nothing was found broken there; per the issue’s own instruction, a non-reproducing report closes with the evidence rather than a manufactured fix.

What does reproduce, trivially, with no loft at all: an open shell wrapped as a TopoDS_Solid (a box missing one face, sewn, then Shape.solidFromShells([shell]), exactly what BRepBuilderAPI_MakeSolid does with a single non-closed shell, no fixing). fixSolid()/healed() correctly demote it to a shell (#442’s contract), isValid reads true on the result, and this is where the issue’s own complaint held: analyze(tolerance:) read zero free edges on it regardless.

Root cause, and fixed: OCCTShapeAnalyze (OCCTBridge_Healing.mm) called ShapeAnalysis_Shell::LoadShells() and then read HasFreeEdges()/FreeEdges(). LoadShells() only registers a shell for NbLoaded()/Loaded() bookkeeping; it runs no edge analysis at all. Only CheckOrientedShells() populates the free-edge set those accessors read, exactly as the sibling entry point OCCTShapeAnalyzeShell (backing Shape.analyzeShell()) already calls it. So freeEdgeCount was hardcoded to 0 for every shape passed to analyze(tolerance:), however open, and freeFaceCount’s own local variable was never incremented anywhere in the function; both silently agreed with a defect no matter what it was. Now CheckOrientedShells(shell, alsofree: true) is called, freeEdgeCount reports the real count, and freeFaceCount counts the shells found not fully closed, matching the field’s own existing header comment (“Number of free faces (shell not closed)”).

Shape.isValidSolid already answered the issue’s actual question, unaffected by either bug, for a reason unrelated to this fix: added for #206/#208 (an unrelated self-intersecting-loft hazard), it checks shapeType == .solid before running BRepCheck_Analyzer, so it reads false on any demoted shell where plain isValid reads true. It had no test at all for that branch: every existing assertion (Issue225ThreadedRodTests, Issue257MultiStartTests, Issue397CircularHoleTests, OCCTShapeHealingTests) only tested the positive, already-a-solid case. Also documented: selfIntersectionCount is another permanent 0 (the bridge’s own comment: “would require more expensive computation”), not a computed absence of self-intersection.

healed() gained the demotion warning fixSolid() has carried since #442 (it shares the same ShapeFix_Solid mechanism, confirmed by reading ShapeFix_Shape.cxx’s delegation), and both now cross-reference isValidSolid as the reliable check. This is a bug fix to the values Shape.analyze(tolerance:) returns, not a new field or a signature change. Like the #605/#609/#583/#595 fabricated-zero fixes before it, this is a PATCH-level correction (a wrong value repaired, per SEMVER.md’s own quick-reference table), not a recorded exception: nothing could have correctly relied on a hardcoded 0.

New tests: Tests/OCCTShapeHealingTests/Issue702SolidDemotionTests.swift, 11 cases on the tiny open-shell fixture above (a box missing one face is the “smallest shape that demotes” the issue asked for). Proven to catch the fix: reverting OCCTShapeAnalyze back to LoadShells() fails exactly the three tests exercising freeEdgeCount/freeFaceCount (open shell, demoted shell, a two-shell compound) and leaves the other seven (isValidSolid, demotion-to-shell, closed-box negative control) green, confirming the new tests target this bug specifically rather than the already-correct demotion behaviour.

Follow-up from code review, before this landed:

  • OCCTShapeAnalyze’s new CheckOrientedShells call omitted checkinternaledges, the third argument, unlike the sibling OCCTShapeAnalyzeShell, which already passed true. ShapeAnalysis_Shell.cxx: with checkinternaledges false, a FORWARD/REVERSED edge with no opposite-orientation partner is unconditionally free; with it true, an edge that also occurs with TopAbs_INTERNAL orientation elsewhere in the same shell (matched by IsSame: same TShape and Location, ignoring orientation) is read as connected through that occurrence instead. The two entry points could silently disagree on any shell carrying an INTERNAL-oriented edge, directly contradicting this fix’s own “analyze() must agree with analyzeShell()” test invariant. The original fixture (the box missing one face) has no INTERNAL-oriented edges, so it could not catch the divergence; a new fixture does (a single face wrapped as a shell, with an .internal-oriented duplicate of one of its own boundary edges embedded back into the face before the face joins the shell). Both call sites now share one helper (occtAnalyzeShellOrientation), so the argument cannot drift between them again the way it just did.
  • totalProblems double-counted an open shell’s defect: once via freeEdgeCount (one count per free edge) and again as a flat +1 via freeFaceCount (one shell found not fully closed), now that this fix makes both fields live for the same shape at once for the first time. freeFaceCount is a derived summary of the same scan, not an independent defect category, so totalProblems no longer includes it; freeFaceCount stays a public field for callers who want the shell-level breakdown.
  • The per-shell scan gained a per-iteration try/catch. CheckOrientedShells is a real OCCT computation, unlike the LoadShells() it replaced, so it can raise Standard_Failure on a malformed shell; without a scoped catch, one bad shell in a multi-shell shape would abort to this function’s outer catch and discard every other shell’s free-edge count along with the small-edge/small-face/gap counts computed afterward, rather than just skipping that one shell’s contribution.

A second, corrected review pass retracted five of the first pass’s seven points (a docs gap on fixed/upgraded, the per-shell try/catch above, duplicated scan logic, and two writing-style nits) as not part of its own verified output, keeping only two (checkinternaledges and the totalProblems double-count, both already listed above) and replacing the rest with two genuinely different findings:

  • isValidSolid, isValid and healed()’s doc comments carried no fenced swift code block. isValid and healed() gained new demotion-hazard prose in this same fix and neither picked up an example; isValidSolid had none even before this fix touched its doc, so the review’s claim held on all three. All three now carry a runnable snippet that builds the box-missing-one-face fixture and shows isValid == true / isValidSolid == false on the demoted shell: the exact hazard each paragraph describes, not a decorative unrelated example. Each snippet’s literal code was run once as a throwaway test before being written into the doc comment.
  • Issue702SolidDemotionTests.openShellMissingOneFace() duplicated a fixture Issue442FixSolidMultiBodyTests.swift already built inline, in the same test target: both drop one face from a box, compound the remaining five and sew them, the smallest input that reaches ShapeFix_Solid’s cannot-close branch. Extracted to Tests/OCCTShapeHealingTests/ShapeHealingTestFixtures.swift (sewnBoxMissingOneFace(_:tolerance:), parameterized on the box so the two suites keep their own box size/origin), following the precedent Tests/OCCTStressTests/StressTestFixtures.swift already set for sharing fixtures within one test target. Both suites re-verified unchanged after the refactor.

CI builds the same kernel the branch is written against

Package.swift now pins the v2.0.0-kernel.1 pre-release: upstream V8_0_1 plus the eleven carried patches 0010-0012 and 0014-0021. It previously pinned v1.15.18, which is V8_0_0_p1 + patches 0001-0016.

That mismatch made ci.yml’s macOS job useless as a signal on this branch. Every test asserting behaviour a newer patch fixes failed there, indistinguishably from a real regression, and seven suites were red for that reason alone: #522 approximation collapse, #570 healing approximations, both #572 sweep and conversion suites, #491 approximation parity, #496 cylindrical hole contracts and #532 hole part selection. Every new correctness fix added more, and the documented workaround was to read kernel-integration.yml instead (#585).

Against the new pin, with no local Libraries/ and no OCCTSWIFT_LOCAL, the full suite is 5,313 tests, 0 failures. OCCTSWIFT_LOCAL=1 remains useful for iterating on a locally rebuilt kernel; it is no longer required to get correct results.

The pre-release is not a library release, and is not installable as one. The v2.0.0 release commit re-points url:/checksum: at the final asset (#512), but the pre-release itself is kept: every commit in the v2.0.0 window pins it, so deleting it would take its asset with it and make those commits unbuildable from a clean checkout, breaking git bisect and historical re-measurement.

Verified before publishing, per docs/guides/building-occt.md’s shipping checklist: occt-src at exactly V8_0_1, all eleven patches reverse-apply, and zero modified files that no carried patch touches, so no investigation probe is compiled in. The published asset re-downloads to the same SHA256 it was uploaded with.

OCCT re-pinned to 8.0.1

The source pin moves from V8_0_0_p1 to V8_0_1 (2026-07-30), the first maintenance release in the 8.0 series. Against p1 it is a clean fast-forward (23 commits, 74 files, nothing reverted) and it carries no API or ABI break: the only public header in the whole diff is ShapeAnalysis_FreeBounds.hxx, changed by one comment line. Nothing to migrate for the OCCT API itself.

Package.swift’s url:/checksum: pointed at the v1.15.18 asset when this landed, which is p1 + patches 0001-0016, on the #512 rule that the bump belongs to the release commit. That is no longer the case: see “CI builds the same kernel the branch is written against” above, which pins the v2.0.0-kernel.1 pre-release. A clean checkout with no local Libraries/ now resolves the right kernel, and ci.yml’s macOS job is a real signal. OCCTSWIFT_LOCAL=1 remains useful for iterating on a locally rebuilt kernel; it is no longer needed for correct results.

edges() keeps its orientation collapse: audited, no consumer needs it (#638)

Documentation only, no code change.

Shape.edges() has the identical TopExp::MapShapes/IsSame collapse #614 fixed for faces(): an edge reachable from two owners collapses to one entry carrying whichever orientation was reached first. Measured on a plain box, edges().count/edgeCount report 12 distinct edges while Shape.contents.edges (an independent, already-documented occurrence count) reports 24: one per (face, edge) visit.

#614 was a defect because Face.normal(atU:v:) reverses on TopAbs_REVERSED, so a face’s stored orientation changed the answer a caller got. This issue’s own text refused the analogy and asked whether the same is true for edges before assuming it. It is not: Edge exposes no .orientation accessor at all, and every geometric query on it (tangent(at:), point(at:), parameterBounds, curvature(at:)) reads the edge’s underlying Geom_Curve through BRep_Tool::Curve, which is defined independently of TopAbs_Orientation. A bridge-wide grep for .Orientation() == across Sources/OCCTBridge/src/*.mm finds 14 branching sites: 13 are face-normal logic #614 already covers, and the fourteenth (occtSampleWirePoints, OCCTBridge_Modeling.mm) reads orientation fresh off a BRepTools_WireExplorer walk of the wire it samples, never from an edge Shape.edges() produced.

Verified beyond the source read: constructing the identical edge with both TopAbs_Orientations (Shape.subShape(type:index:).reversed) and comparing every accessor Edge exposes shows them bit-for-bit identical, on both a straight and a circular edge.

Decision: document the contract, add no orientedEdges(), change no behaviour. This is the outcome this issue’s own text names as correct when no orientation-dependent consumer exists. Shape.edges()/edgeCount’s doc comments and docs/reference/Edge.md now state the collapse explicitly, matching how Shape.faces() documents its own. Regression tests in Tests/OCCTTopologyTests/Issue638EdgeOrientationContractTests.swift pin both halves: the dedup mechanism itself (12 distinct / 24 occurrences on a box, 20 distinct on the #614 split fixture), and the orientation-independence of Edge’s per-instance accessors, verified by injecting a hypothetical orientation-aware tangent and watching it fail before restoring it.

See Scripts/repro/cluster-a-subshape-enumeration/ (#664) for the full census this decision rests on.

Ten carried kernel patches retired

8.0.1 ships our own upstream contributions, so Scripts/patches/ goes from 21 files to 11. Retired: 0001-0009 and 0013, covering #263, #280, #298, #310, #317, #318, #323 (three) and #348. Every merge commit was confirmed an ancestor of the tag rather than merely merged to master, and every patch was diffed against its as-merged form before its file was deleted, because review can change a patch between submission and merge.

Nine came back unchanged. 0001 did not: upstream’s merged form also guards a removed face (anApplied.IsNull() ||) where ours checked only the shape type, so that retirement fixes a latent null dereference our own patch had. Per-patch verdicts are in Scripts/patches/README.md under “Retired patches”.

Patch numbers are not reused: the carried sequence now reads 0010-0012, 0014-0021, and the gaps are the retirements. Renumbering would have repointed every citation in CLAUDE.md, docs/, closed issues and Scripts/repro/ at a different fix.

Behaviour changes 8.0.1 brings, measured

The full suite is green on the new kernel (5313 tests, 1400 suites, 0 failures), but a green suite is not evidence for two of these, because no test in the repo uses INTERNAL/EXTERNAL edge orientation and #645 records that the Gordon tests assert only status ordinals. Those two were probed directly. Tracked in #654.

  • GeomFill_Gordon was returning a 5% wrong surface, and 8.0.1 fixes it. Handed the isocurves of the surface it should reproduce, a rational network (quarter cylinder, r=5) deviated 0.251262658 from its own input while reporting IsDone; it is now 2.6e-15. The 45° sample moves from 3.713203436 to 3.535533906, which is exactly 5/√2. A non-rational control was already exact on both kernels, so only rational networks ever moved. Affects Surface.gordon(profiles:guides:tolerance:) and Surface.gordonReport(...). Evidence added to #645.
  • Shape.sectionWiresAtZ(_:tolerance:), not freeBounds*, sees the INTERNAL/EXTERNAL skip (#655). ShapeAnalysis_FreeBounds::ConnectEdgesToWires now skips edges oriented .internal/.external: a sequence of one INTERNAL edge plus a square returns 1 wire where it returned 2, and a sequence of nothing but INTERNAL/EXTERNAL edges returns 0 wires where it returned 1. That C++ function appears exactly once in this bridge, inside OCCTShapeSectionWiresAtZ, which backs Shape.sectionWiresAtZ(_:tolerance:), a CAM sectioning API: in every case measured, an ordinary transverse cut does not produce such an edge, but a cut plane coincident with an edge a caller already marked .internal/.external via Shape.setOrientation(_:) does move, 2 wires to 1 on a measured fixture. The freeBounds* family does not move, but not because its call graph avoids the change: Shape.freeBounds, freeBoundsClosedCount, freeBoundsClosedWires and freeBoundsOpenWires build ShapeAnalysis_FreeBounds’s (shape, tolerance) constructor, whose own chainage step reaches the same skip. The reason is upstream, the edges that constructor feeds in come from BRepBuilderAPI_Sewing::FreeEdge, and in every case measured that sewing stage never produced an .internal/.external free edge for the skip to act on. freeBoundsAnalysis, FreeBoundsProperties and its four accessors take a second constructor at tolerance <= 0, with no sewing stage at all, so that reason does not apply to them; measured separately, the exclusion holds there too.
  • BRep_Tool::CurveOnPlane fails differently. An out-of-domain, inverted or zero-length edge range now yields a null pcurve where 8.0.0p1 threw a catchable Geom_TrimmedCurve::parameters out of range. All four probed cases changed.
  • No movement from the BRepMesh periodic-seam fix, the ChFi3d_Builder::StartSol hardening, or the BRepCheck_Face/GeomLib_CheckCurveOnSurface fast paths. For those three the green suite is evidence, since they are well covered.

Fixed: evalAndUpdateTolerance handed OCCT an unguarded null pcurve

Shape.evalAndUpdateTolerance(edge:face:) was an uncatchable SIGSEGV whenever the edge had no pcurve on the given face and that face was not planar, which is routine for mesh-sewn topology. The bridge guarded the 3D curve and the surface but passed the pcurve straight into BRepTools::EvalAndUpdateTol, which dereferences it at if (!C2d->IsPeriodic()).

Fixed here rather than deferred because the re-pin adds a second route to it: with #1402 returning null instead of throwing, the crash became reachable on a planar face too, where 8.0.0p1 returned a safe 0.0. Shipping the absorb without the guard would have introduced a new crash path. #656.

build-occt.sh could not change OCCT version

It cloned occt-src only when the directory was absent, so bumping OCCT_VERSION/OCCT_RC was a silent no-op on any machine that had built before: the old tag’s sources were compiled and packaged under the new version’s number. It now requires HEAD to be at the tag the script names and aborts otherwise, naming the tree so a diagnostic probe left by an investigation is not destroyed silently.

GeomTools_Curve2dSet/SurfaceSet null-handle census: closed on evidence, kernel patch carried (#643)

Cluster C’s own census (#666, PR #711) concluded, without re-measuring, that #643 is already bridge-guarded and the remaining defect is upstream. Re-verified rather than inherited, per this issue’s own instructions.

The guard holds. OCCTGeomToolsCurve2dSetWrite/OCCTGeomToolsSurfaceSetWrite (Sources/OCCTBridge/src/OCCTBridge_IO.mm) already refuse a null handle per array element before calling GeomTools_Curve2dSet::Add/GeomTools_SurfaceSet::Add (the #618 “array element through a cast” shape), and these two functions are the only call sites of either class anywhere in the tree. Confirmed dynamically too, not just by reading the guard: override-linking the real OCCTBridge_IO.mm against a genuinely null-handle-wrapping OCCTCurve2D/OCCTSurface returns nullptr rather than crashing, for both a null-only array and a mixed valid+null array. Removing the guard (injected, then restored) reproduces the SIGSEGV through the real bridge function, confirming it is load-bearing rather than incidental.

The upstream asymmetry is real and still live. GeomTools_CurveSet::Add guards a null handle and drops it (return (C.IsNull()) ? 0 : myMap.Add(C);); GeomTools_Curve2dSet::Add and GeomTools_SurfaceSet::Add don’t, so a null is bound at a valid-looking index and only crashes later, inside Write(). Re-measured directly against the pinned kernel (v2.0.0-kernel.1, OCCT V8_0_1 + eleven carried patches) rather than assumed from the issue’s original 8.0.0p1 report: identical result. A second, previously unnamed divergence was found checking whether the three writers disagree anywhere else: Index() has the same asymmetry (CurveSet::Index guards, the siblings don’t). It doesn’t crash (NCollection_IndexedMap::FindIndex never dereferences its argument), but the two siblings silently report a bogus non-zero index for a handle that was never validly bound, where CurveSet::Index correctly answers 0. Both files are byte-identical between the pinned V8_0_1 tag and current upstream master.

Filed upstream and carried, not fixed bridge-side. This repo’s own guard already prevents any caller from reaching the defect, so the fix belongs in the container, not the wrapper. Kernel patch Scripts/patches/0023-GeomTools_Curve2dSet-SurfaceSet-null-handle-643.patch mirrors GeomTools_CurveSet’s own correct guard onto Add/Index on both siblings (four one-line changes), verified by override-linking the patched files ahead of the unpatched archive: all three classes’ Add() now return 0 for a null handle and Write() completes normally; all three classes’ Index() now return 0. Filed as Open-Cascade-SAS/OCCT#1434 (repro) / OCCT#1435 (fix). Inert until the pinned xcframework is rebuilt (#512); this change does not rebuild or bump the pin.

No bridge behavior change, no public API change. See Scripts/repro/643-geomtools-null-write/ for the reproducer and the full “prove the test fails” injection matrix (bridge guard removed → SIGSEGV reproduced; kernel patch applied → both defects resolved).

chamfer2D SIGSEGVs, uncatchably, on a repeated edge pair (#705)

Found by Cluster B’s edge/vertex-index census (#665, Scripts/repro/cluster-b-fillet-edge-contract/), which records the crash rather than running it live, since an in-process OS signal would kill the census itself. Shape.chamfer2D(edgePairs:distances:) crashed the whole process, uncatchably, when the same edge pair appeared twice:

let wire = Wire.polygon3D([SIMD3(0, 0, 0), SIMD3(10, 0, 0), SIMD3(10, 10, 0), SIMD3(0, 10, 0)], closed: true)!
let rectFace = Shape.face(from: wire)!
_ = rectFace.chamfer2D(edgePairs: [(0, 1), (0, 1)], distances: [1.0, 2.0])   // SIGSEGV, exit 139

Confirmed in a separate process (a temporarily-repointed Sources/OCCTTest/main.swift, restored after), since an in-process crash kills the test runner rather than failing one test:

Input Before After
[(0, 1), (0, 1)] SIGSEGV, exit 139 nil, exit 0
[(0, 1), (1, 0)] (reversed, same pair) SIGSEGV, exit 139 nil, exit 0
[(0, 1), (0, 1), (0, 1)] SIGSEGV, exit 139 nil, exit 0
[(0, 1), (1, 2)] (one edge, two different pairs) non-nil, unaffected non-nil, unaffected
[(0, 1), (1, 2), (2, 3), (3, 0)] (every corner) non-nil, unaffected non-nil, unaffected

The crash is inside the repeat call’s own BRepFilletAPI_MakeFillet2d::AddChamfer, an OS signal the bridge’s catch (...) cannot absorb, and it is an upstream OCCT defect, not this bridge’s own. AddChamfer(edge1, edge2, ...) calls ChFi2d::FindConnectedEdges to look up the pair’s shared vertex and dereferences the two edges it returns without checking the returned status first, and that lookup leaves both edges null on every failure path. A pair’s second call fails the lookup, because its shared vertex was already consumed chamfering the pair the first time. The sibling overload (AddChamfer(edge, vertex, distance, angle)) checks the identical status correctly, which is the precedent the upstream filing cites. Reusing one edge across two different pairs, i.e. chamfering adjacent corners of a polygon, the ordinary multi-corner case, does not crash, measured above. Only the identical pair repeated does, order-independent. A kernel patch is carried separately, tracked in a follow-up PR; the guard below is what protects callers until it ships.

Fixed: OCCTFace2DChamfer (OCCTBridge_Modeling.mm) now checks each pair against every prior pair in the same call before invoking AddChamfer, and rejects the whole request (returns nullptr) on a match in either order. This matches fillet2D(vertexIndices:radii:)’s own contract for a duplicated vertex on the same builder (#568) and the #568 idiom already used one line above in the same function for an out-of-range index: the whole call fails rather than guessing which of two distances to keep. #633 is open on the wider family’s duplicate-index direction (fillet is last-wins, chamfer is first-wins) and is deliberately not settled here; this fix removes a crash, not a vote in that debate, though it is recorded as a data point for it.

This is a behaviour change on a public API with no compile error, recorded in SEMVER.md. A call with no repeated pair, which includes every existing caller, is unaffected. Tests: Tests/OCCTModelingTests/Issue568IndexSkipTests.swift’s chamfer2DRejectsDuplicatePair and chamfer2DAcceptsSharedEdgeAcrossDifferentPairs, the latter proven to catch an overly broad fix by injecting one (reject on any repeated single index rather than a repeated pair) and confirming it turns the shared-edge test red, then restoring.

The census’s own row for chamfer2D is updated from CRASH (SIGSEGV, uncatchable) to the measured REJECT (nil), now safe to run live: swift run Censuses cluster-b calls it directly rather than noting it as unsafe to run.

Pass 1b of the #377 duplication audit

PipeSweepMode.frenet and .correctedFrenet were wired to each other’s OCCT mode (#598)

Found while measuring #572, which needed the pipe shell driven in a specific trihedron mode. BRepOffsetAPI_MakePipeShell::SetMode’s parameter is named IsFrenet, and its header (confirmed against the pinned V8_0_1 source before changing anything) says so: “If IsFrenet is false, a corrected Frenet trihedron is used.” occtPipeShellSetMode (OCCTBridge_Modeling.mm:494) passed the opposite boolean for both enum cases: OCCTPipeModeFrenet called SetMode(Standard_False) (corrected Frenet) and OCCTPipeModeCorrectedFrenet called SetMode(Standard_True) (plain Frenet). PipeSweepMode.frenet is the default mode: on all three Shape.pipeShell* spellings, so this affected every caller who never named a mode, not only one who asked for .frenet explicitly.

Measured, not assumed, that the two modes actually differ, using an independent oracle: PipeShellBuilder.setFrenet(_:) calls BRepFill_PipeShell::Set(frenet) directly, a separate bridge function this bug never touched. On a curved B-spline spine (the same fixture Issue503PipeShellTests.curvedSpine() uses) with a rectangular profile, Shape.pipeShell’s .frenet and PipeShellBuilder.setFrenet(true) disagreed before this fix (180.286724 vs 177.347557) and agree after it; .correctedFrenet and setFrenet(false) disagreed the other way and now agree too. On a spine with no torsion the two OCCT trihedron laws coincide, so the swap was silent there: a straight-line spine produces byte-identical volumes for .frenet and .correctedFrenet both before and after this fix, confirmed by a test that stays green with the defect deliberately reinjected.

Fixed: swap the two SetMode booleans in occtPipeShellSetMode. A second, related site was found in the same file and left behavior-unchanged: OCCTShapeCreatePipeShellWithLaw (line 4134) hardcodes SetMode(Standard_False) with a // Frenet comment that was equally wrong (it builds corrected Frenet); that entry point takes no mode: parameter at all, so no public contract changed, only the misleading comment.

Not recorded as a SemVer exception. This lands in v2.0.0, a major version, where SemVer permits breaking changes outright: SEMVER.md’s own words scope its recorded-exception ledger to a break “within a major line”, which a break shipping in the major bump itself is not. An earlier revision of this entry added a fourteenth exception here anyway; review of #715 (this fix’s own PR) caught that as a category error, and it also introduced counter drift in two other places (SEMVER.md stated both “thirteen” and “fourteen” total exceptions in different paragraphs, and left #609 mis-numbered as the “fourteenth” held-for-v2.0.0 break instead of the fifteenth). SEMVER.md is restored to the state it was in before this issue; this changelog entry is the migration note a consumer needs instead.

Blast radius, corrected after review of #715, not fully measured the first time. The original measurement covered a spine with ordinary curvature/torsion and a spine with none; neither is the case .correctedFrenet’s own doc comment names as its reason to exist (“avoids twisting at inflection points”). Measured properly this time, on a planar S-curve whose curvature crosses exactly zero at its midpoint (confirmed by sampling curvature(at:) across the domain rather than assumed from the control points):

Mode Result on the inflection spine
.frenet (the default) Self-intersects (Shape.isSelfIntersecting() reports true)
.correctedFrenet Stays valid (Shape.isSelfIntersecting() reports false)

This is the regression class review of #715 asked for: a caller who never named a mode and sweeps a spine through a curvature inflection now gets an invalid solid where the pre-#598 (wrongly-wired) default happened to build the safer sweep instead, by accident, under the old bug. There is no general substitute for naming .correctedFrenet explicitly here: if a spine’s curvature may pass through zero, do not rely on the .frenet default.

Also corrected: the claim that a circular profile makes the two trihedron laws produce “the identical swept volume” everywhere was wrong, and it was the reason this entry originally gave for why docs/guides/cookbook/helices.md’s spring recipe needed no update. Measured directly against the independent PipeShellBuilder oracle, on the cookbook’s own r=10, pitch=4, turns=5, wireRadius=1.5 helix: .frenet reproduces the textbook tube volume (π·wireRadius²·coilLength, 2225.1497 measured vs 2225.1564 expected) but .correctedFrenet does not (2495.4373 measured, about 12% larger). A circular profile only guarantees the two laws agree on a spine with no torsion (the case actually tested above); a helix has constant, non-zero curvature and does distinguish them. Since the cookbook’s own claimed invariant (spring.volume ≈ π·wireRadius²·(coil length)) now holds only for .frenet on this recipe, the recipe is corrected in this same PR to use .frenet, and its prose no longer claims rotational symmetry makes the two laws interchangeable.

Revised after OCCTSwift #721: the paragraph above was itself wrong, not just the cookbook. The “about 12% larger” measurement placed the profile at SIMD3(r, 0, 0) with tangent normalize(0, r, pitch/2pi), describing that as the helix’s own start point and tangent. It is neither: Wire.helix’s default clockwise: false reverses the build axis to -Z, so the wire’s actual start is (-r, ~0, 0), descending, and the real tangent’s Z-component has the opposite sign. With the profile placed at the spine’s own measured start point and tangent instead, .frenet and .correctedFrenet agree with each other and with the textbook volume to ~1e-6 relative, at every pitch and turn count tested (see #721’s own PR for the full sweep). The rotational-symmetry argument this entry originally walked back was correct after all; the measurement that contradicted it was constructing the wrong profile, not testing a real .correctedFrenet limitation. docs/guides/cookbook/helices.md’s recipe and this repo’s own Issue598PipeShellFrenetModeTests.cookbookSpringRecipeVolumeInvariant are corrected in #721’s PR to measure the tangent from the wire rather than compute it analytically, and .correctedFrenet is restored as a mode that also produces the textbook volume on a helical spring.

Every other existing pipe-shell call site in this repo’s tests uses either a straight spine or a non-Frenet mode (.fixed(binormal:), .auxiliary(spine:)) this bug never touched. Exactly one existing hardcoded literal needed updating: Issue503PipeShellTests.fixedBinormalDiffersFromFrenet’s pinned .frenet volume moves from 180.286724 to 177.347557. Shape+Modeling.swift’s own pipeShell doc snippet had the same stale literal (180.29) and is corrected to 177.35.

New tests: Tests/OCCTModelingTests/Issue598PipeShellFrenetModeTests.swift. Six cases: .frenet and .correctedFrenet each matched against the PipeShellBuilder oracle on the curved spine (and confirmed to disagree with the other oracle value, so a match isn’t a coincidence of tolerance), the multi-section spelling inheriting the same fix through the shared helper, the no-torsion control, the curvature-inflection self-intersection case above, and the cookbook recipe’s volume invariant above. curvedSpine() and the scale-relative tolerance helper are no longer duplicated in this file: review of #715 found three near-identical copies of the same tolerance formula (with two different default tolerances) split across this file and Issue503PipeShellTests.swift (same target); both fixtures are now promoted from Issue503PipeShellTests.swift and reused directly instead.

Proved each new/changed case catches its own defect, per test rather than as a suite total: reinjecting the original swap (SetMode(Standard_False) for OCCTPipeModeFrenet, SetMode(Standard_True) for OCCTPipeModeCorrectedFrenet), rebuilding, and rerunning:

Test With defect reinjected With fix restored
frenetMatchesTrueFrenet FAIL (3 issues) PASS
correctedFrenetMatchesTrueCorrectedFrenet FAIL (3 issues) PASS
multiSectionSpellingAlsoFixed FAIL (1 issue) PASS
theTwoModesAgreeOnASpineWithNoTorsion PASS (confirms silence) PASS
frenetSelfIntersectsAtCurvatureInflection FAIL (2 issues) PASS
cookbookSpringRecipeVolumeInvariant FAIL (4 issues) PASS
Issue503PipeShellTests.fixedBinormalDiffersFromFrenet FAIL (1 issue) PASS

13 issues across 5 new/changed tests plus the pre-existing literal, while the no-torsion control stays green throughout, confirming it really is a silent case rather than an assumption. Restoring the fix returns all of them to green.

The fillet family could not report a declined edge, only skip it silently (#639)

BRepFilletAPI_MakeFillet::Add does nothing, with no exception and no false return, for an edge it cannot fillet, most commonly a free-boundary edge of an open shell, which has only one adjacent face where a fillet needs two. filleted(edges:radius:), filleted(edges:startRadius:endRadius:) and filletEvolving(_:) have always skipped such an edge rather than rejecting the whole call (#612), correctly, but had no way to tell a caller which edges those were, or how many. The Cluster B census (Scripts/repro/cluster-b-fillet-edge-contract/) measured the gap concretely: filleting all 12 edges of an open shell (a box with one face dropped, sewn) accepts 8 and silently declines 4 ([6, 9, 10, 11]), and every one of these three entry points reports plain success.

The decision this issue asked for, made explicitly: report, do not reject. Converging every declining entry point onto rejecting a batch with any declined edge would change filleted(edges:radius:), filleted(edges:startRadius:endRadius:) and blendedEdges(_:) on every open shell: a behaviour change wider than this issue, and the same mistake an earlier draft of #633 made and withdrew. Skip stays the answer; the fix is observability, following #482’s FillingSurface.refusedConstraintCount precedent named in the issue.

What OCCT can actually say, measured before designing around it: Add returns nothing, and BRepFilletAPI_MakeFillet::NbFaultyContours()/BadShape()/StripeStatus() describe a contour that failed during Build(), which an edge OCCT never added to any contour never reaches. The one signal OCCT does expose is Contour(edge) == 0, populated by Add() itself rather than by Build(). So this reports which edges were declined (a list of indices, not just a count: the issue’s own “count is cheap, naming which is more useful” argument, taken as far as it goes), and not why: no reason is reachable from this API.

Fixed for the three entry points that genuinely had no side channel: filleted(edges:radius:) and filleted(edges:startRadius:endRadius:) (OCCTShapeFilletEdges/OCCTShapeFilletEdgesLinear, sharing occtShapeFilletEdgeList) and filletEvolving(_:) (OCCTShapeFilletEvolving) each gained a WithReport sibling, filletedWithReport(edges:radius:), filletedWithReport(edges:startRadius:endRadius:), filletEvolvingWithReport(_:), returning a new Shape.FilletResult { shape, declinedEdgeIndices }. The bridge computes the report with a new shared helper (occtFilletDeclinedIndices/occtFilletWriteDeclined, OCCTBridge_Internal.h) that re-checks Contour(edge) for each requested index after every Add() and before Build(); the three underlying bridge functions gained two nullable trailing out-parameters the existing non-reporting call sites pass nil for, so nothing about their cost or behaviour changes.

Two of the issue’s own named members already carried the mechanism, measured rather than assumed missing: filletedWithFullHistory(radius:edges:)’s ShapeHistoryRef distinguishes a declined edge from an accepted one via !record.isDeleted && record.generated.isEmpty. An edge OCCT actually filleted is always deleted with the new fillet-boundary edges in generated (12/12 on the fixture). The first hypothesis for this recipe was wrong and caught by measuring it: a declined edge is not necessarily modified.isEmpty too. On this fixture every declined edge shows exactly one modified entry, a different edge instance with a shorter length (10.0 to 8.0), because an accepted neighbour’s fillet trims the declined edge’s shared endpoint. modified alone is not the signal; generated/isDeleted are. Separately, FilletBuilder.contour(for:), already present and unrelated to this issue, answers the identical Contour(edge) == 0 question directly, readable right after addEdge with no build() required, matching the census’s measured set exactly before and after build(). Both are documented (ShapeHistoryRecord, FilletBuilder) with a runnable recipe rather than given new bridge code, since there was nothing to add.

blendedEdges(_:), #633’s own site, does not adopt this mechanism here. It is the same SKIP behaviour on the same declined-edge axis, so the same WithReport shape would apply, but #633 is about a different axis of this contract (a duplicated edge index silently discards a radius) and is scheduled after this PR specifically because the reporting decision made here might change what #633 should do. Recommendation for that issue: adopt the same FilletResult-shaped report, extended to also carry which duplicate indices were overwritten, since the same “list what happened, do not change what SKIP or OVERWRITE means” principle applies to it too.

New tests: Tests/OCCTModelingTests/Issue639FilletDeclinedEdgeReportTests.swift, seven cases covering all five members named in the issue (the three WithReport siblings, the FilletBuilder recipe, the history recipe) plus two “empty on a closed solid” negative controls. Each of the five declined-set assertions was proven to catch its own mechanism, not just some regression elsewhere: reverting occtFilletWriteDeclined to report nothing fails exactly the three WithReport tests (the two negative controls correctly stay green, since they expect empty anyway); separately reverting OCCTFilletBuilderContour fails exactly the FilletBuilder test; separately reverting OCCTBooleanHistoryIsDeleted fails exactly the history test.

This is additive, non-breaking Swift API (three new methods, one new struct, no existing signature changed), recorded in SEMVER.md per #664’s discipline of writing this down now rather than at tag time, even though nothing here moves the file’s own “twelve recorded exceptions” count. Scripts/repro/cluster-b-fillet-edge-contract/ is unchanged: the census measures the contract, this issue only adds a way to observe one axis of it, and the measured grid itself (SKIP/REJECT per cell) does not move.

blendedEdges(_:) reports which duplicate entries were overwritten (#633)

The last open member of Cluster B (#665). BRepFilletAPI_MakeFillet::Add(radius, edge) resolves an edge’s own slot within its fillet contour and writes there, so naming the same edge index twice in blendedEdges(_:)’s edgeRadii writes that slot twice – the second Add silently overwrites the first, with no exception, no false return and nothing in the result to say a radius was ever discarded. Re-measured on this tree rather than trusted from the issue: blendedEdges([(0, 2.0), (0, 5.0)]) gives the identical volume (946.349541) to blendedEdges([(0, 5.0)]) and differs from blendedEdges([(0, 2.0)]) (991.415927) – last-wins, confirmed.

Sequenced deliberately after #639, which decided the reporting mechanism for the family’s other silent axis (an edge OCCT declines outright) and recommended, in its own PR, extending the same shape to also cover this one rather than re-deriving a mechanism. That recommendation is taken here without a reason found to deviate: report, do not reject or pick a “winning” direction. Converging blendedEdges(_:) onto reject, or onto first-wins (matching the chamfer family, see below), would change what it returns for every existing caller who happens to name an edge twice and currently gets a built shape – a behaviour change wider than this issue, and the same move #639 rejected for its own axis.

Fixed: blendedEdgesWithReport(_:) is a new WithReport sibling, returning Shape.FilletResult, now with a second field, overwrittenDuplicateIndices – the 0-based edge indices whose radius a later entry in the same request overwrote, mirroring the request list the same way declinedEdgeIndices already does (an edge named three times reports two overwritten entries, not one distinct index). Computing it needs no OCCT round trip at all: an edge index maps to exactly one edge via Shape’s own TopExp enumeration, so which entries lose is a property of the caller’s edgeRadii array alone, resolved Swift-side. blendedEdgesWithReport(_:) also adopts #639’s declined-edge mechanism for this entry point (OCCTShapeBlendEdges gained the same two nullable trailing out-parameters OCCTShapeFilletEdges/OCCTShapeFilletEdgesLinear already carry, threaded through the shared occtShapeFilletEdgeList skeleton) rather than leaving that field permanently empty for this one sibling: a report that answered one axis and silently stayed blank on the other would be the same shape of silent-wrong-answer this whole cluster exists to fix. blendedEdges(_:) itself, and every other FilletResult-returning method, are unchanged – the new field defaults to [] for filletedWithReport(edges:radius:), filletedWithReport(edges:startRadius:endRadius:) and filletEvolvingWithReport(_:), none of which has a duplicate axis of its own to report.

filletEvolving(_:) measures the identical last-wins mechanism (documented on EvolvingFilletEdge’s own doc comment, and confirmed again by this PR’s re-measurement), and is deliberately left alone here: extending filletEvolvingWithReport(_:) with the same field is a natural follow-up, not a requirement of this issue, which is scoped to blendedEdges(_:) by its own title.

New tests: Tests/OCCTModelingTests/Issue633BlendedEdgesDuplicateReportTests.swift, seven cases. Proven against three independent injections, each restored and confirmed green afterward:

Injection Result
The Swift-side helper drops the report (return []) The 3 tests asserting a non-empty overwrittenDuplicateIndices fail; the 4 that expect it empty, or check declinedEdgeIndices only, correctly stay green
The Swift-side helper deduplicates (Array(Set(overwritten))) instead of mirroring the request Only the triple-duplicate test fails ([0, 0] collapses to [0]); the single-duplicate test is insensitive to this distinction and stays green, as does everything else
OCCTShapeBlendEdges stops forwarding its two new out-parameters (nullptr, nullptr always) The 2 tests asserting a non-empty declinedEdgeIndices fail; both duplicate-only tests, unaffected by this axis, correctly stay green

This is additive, non-breaking Swift API (one new method, one new field on an existing struct, no existing signature or behaviour changed), recorded in SEMVER.md – checked and confirmed the file’s “thirteen recorded exceptions” count is unaffected. Scripts/repro/cluster-b-fillet-edge-contract/ and Scripts/repro/censuses/ClusterB.swift are updated to annotate the now-fixed cell; the measured grid itself does not move (swift run Censuses cluster-b still emits 16 rows).

AAG rode the lossy faces(), so detectPocketsAAG() answered 2 or 1 for the same geometry depending on compound member order (#642)

AAG.buildGraph() built its node set from Shape.faces(), the IsSame-keyed enumeration #614 documented as orientation-insensitive by design: a face occurring both FORWARD and REVERSED collapses to one entry, keeping whichever orientation was reached first. AAGNode.isHorizontal/ isUpward/isDownward/isVertical/zLevel are all derived from that entry’s normal, and AAG.detectPockets() selects floors on exactly those fields, so which orientation survived the collapse silently decided the answer. #614 already fixed horizontalFaces(), upwardFaces() and facesByZLevel() by routing them onto orientedFaces(); AAG was a fourth consumer that PR did not reach, because it was never enumerated as one.

Measured on an origin-centred 10mm box cut through z=4 and recompounded in both member orders, the exact fixture Cluster A’s census (#664) confirmed reproduces this: detectPocketsAAG().count was 2 in one order and 1 in the other, and the AAG upward+horizontal node set was [2, 8] against [2], for identical geometry. #614’s own committed fixture, a vertical rather than horizontal cut, does not exercise this at all (its shared wall’s normal is horizontal-axis, never reaching isHorizontal()): a regression test built from that fixture would have found no order-dependence and wrongly concluded the defect did not exist. That correction came from the census, not this issue’s own text.

Fixed by moving AAG.buildGraph() onto Shape.orientedFaces(): a face shared between two solids in a compound is now two nodes, one per owning solid, each carrying that solid’s own normal, rather than one node carrying whichever normal the dedup happened to keep. AAGNode gains distinctFaceIndex, the node’s position in the old faces() enumeration, so a caller can still tell the two sides of a shared face apart or recover the previous one-node-per-face view. buildGraph() also gained a guard skipping any pair of nodes that share a distinctFaceIndex: without it, OCCTFacesAreAdjacent (which compares edge sets by IsSame, ignoring orientation) reports every one of a shared face’s own boundary edges as adjacent to itself, since both occurrences bound the identical edge set.

Two other approaches were considered and rejected, per the precedent #614 set of naming rejected alternatives: carrying both normals on one node per distinct face (preserves the old index model, but pushes the “which side” ambiguity onto every caller of isUpward/isHorizontal/etc. instead of resolving it once), and restricting AAG to single-solid input (never established as the only valid use, and would not fix anything for the multi-solid case it was already used for).

A second, different, pre-existing defect surfaced while verifying the fix, and is deliberately not fixed here. On the vertical-cut fixture, detectPocketsAAG().count moved from agreeing (1/1) to disagreeing (1/2) across member order, confirmed by reverting buildGraph() alone to show the identical asymmetry already existed before this fix and only cancelled out by coincidence in the total count. The mechanism: OCCTFacesAreAdjacent/OCCTEdgeGetConvexity have no concept of solid membership, so a face occurrence’s adjacency and convexity are checked against every topologically coincident neighbor, including one that belongs to a different solid than the occurrence’s own orientation. Doubling the shared wall’s node count made this pre-existing ambiguity fire more often. Recorded in Scripts/repro/cluster-a-subshape-enumeration/README.md rather than fixed, since it is a different mechanism than #642’s own (a graph lacking solid-membership tracking, not a normal losing information across a dedup collapse) and out of this issue’s scope.

This is a behaviour change on two public APIs (Shape.buildAAG(), Shape.detectPocketsAAG()) with no compile error, recorded in SEMVER.md. On every shape that shares no face, orientedFaces() equals faces() exactly, so nothing about a single-solid shape’s AAG changes. Tests: Tests/OCCTModelingTests/Issue642AAGNodeIdentityTests.swift, each proven to catch its own mechanism by reverting it alone: reverting orientedFaces() back to faces() fails 5 of 7 tests (the two no-regression tests, on a plain box and on #614’s own vertical-cut fixture, correctly still pass); separately reverting only the distinctFaceIndex adjacency guard fails exactly the one test that exists to catch it.

AAG linked faces across a solid boundary, so #642’s own fix moved a second, independent order-dependence into view (#699)

Found while measuring #642’s own fix. Cluster A’s census (Scripts/repro/cluster-a-subshape-enumeration/) re-ran after #642 landed and caught a NEW disagreement on a fixture #642 does not touch: a plain 10mm box split by an X-normal plane through x=4, recompounded in both member orders, detectPocketsAAG().count was 1 in one order and 2 in the other, for identical geometry. Confirmed with git stash that reverting #642 alone reproduces the identical asymmetry, so this is not something #642 introduced: #642’s fix (doubling a shared face’s node count) exercised it twice as often, which is why a previously-accidental cancellation stopped holding.

The mechanism is different from #642’s, and needs a different fixture to show. #642 was about node identity: which faces become graph nodes. #699 is about edges: which nodes get linked and with what attribute. OCCTFacesAreAdjacent and OCCTEdgeGetConvexity (OCCTBridge_BRepGraph.mm) have no concept of solid membership: both compare two TopoDS_Face values purely on their own edge geometry, ignoring the shape argument they are handed beyond a null check. On a vertical two-solid split, the shared wall borders two half-faces of the box’s original top face that also border each other along the cut line, so one edge is common to three face occurrences (both top-face halves and both sides of the wall). AAG.buildGraph()’s pairwise loop compared all three against each other regardless of which solid each belonged to, linking a face in one solid to a face in another and attributing a convexity meaningless for either. #642’s own fixture (a horizontal cut) never exercises this: its shared wall’s normal is horizontal-axis, so isHorizontal()/isUpward() never reach the duplicated face at all, and conversely #642’s own defect needs a horizontal cut, so neither fixture reproduces the other’s mechanism.

The contract: two faces sharing a B-Rep edge but belonging to different solids in a compound are adjacent in the compound and not adjacent in either solid, and AAG’s consumers (detectPockets(), concaveNeighbors(of:), convexNeighbors(of:)) want the solid-scoped answer.

Fixed by restricting AAG.buildGraph()’s pairwise adjacency/convexity check to occurrence pairs it can establish share a solid, rather than by adding solid-scoping to the bridge functions themselves: an audit found OCCTFacesAreAdjacent/OCCTEdgeGetConvexity have exactly one Swift call site each, both inside buildGraph(), so there was no other consumer a bridge-side change could have broken, and the Swift-side fix is the smaller change either way. Solid membership per occurrence is derived rather than looked up through a new bridge entry point: orientedFaces()’s underlying TopExp_Explorer walk visits every occurrence under one top-level solid contiguously before moving to the next, in the same first-encountered order Shape.solids itself enumerates solids in (both are one DFS over the same shape), so the flat occurrence list partitions into contiguous runs sized by each solid’s own face-occurrence count. Confirmed against both split fixtures (dumping every occurrence’s bounds alongside each solid’s own orientedFaces() count) before relying on it. AAG.buildGraph() falls back to the pre-#699 unrestricted comparison on a shape with zero or one solid, or if the per-solid counts don’t sum to the total occurrence count. Silently mis-partitioning would be worse than not partitioning at all, and neither case arises on any fixture this change measures.

OCCTEdgeGetConvexity did not need its own solid-membership fix on top of this: once adjacency is restricted to same-solid pairs, convexity is only ever computed for a legitimate same-solid neighbor. (A separate, unrelated defect was found while explaining a side effect below: OCCTEdgeGetConvexity’s reported concavity for a given physical edge depends on argument order, which reproduces on a single, uncut box and has nothing to do with solid membership. Recorded in Scripts/repro/cluster-a-subshape-enumeration/README.md rather than fixed here, since it was found explaining a side effect rather than measured as this issue’s own claim.)

A further correction to #642’s own headline measurement. Restricting to same-solid pairs also moves the HORIZONTAL fixture’s detectPocketsAAG().count from 2 (in both orders, #642’s own fix) to 1 (in both orders). This is not a regression: #642 was about order-AGREEMENT, which survives intact (1 in both orders, was 2 in both orders, still equal either way). What moved is that one of the two “pockets” reported before #699 was itself built from a cross-solid comparison between the shared wall and the wrong side of a split face, exactly the mechanism this issue fixes. Tests/OCCTModelingTests/Issue642AAGNodeIdentityTests.swift’s own pinned count moves from 2 to 1 accordingly, with a note explaining why; the AGREEMENT assertion it exists to guard is unchanged and still passes.

This is a further behaviour change on Shape.detectPocketsAAG(), recorded alongside #642’s own entry in SEMVER.md rather than a new one, since it is the same public API. On every shape with zero or one solid, nothing changes. Tests: Tests/OCCTModelingTests/Issue699AAGSolidScopedAdjacencyTests.swift, each proven to catch its own mechanism by reverting the fix alone (7 assertions across 3 tests fail, reproducing the exact 1-vs-2 and 2-vs-1 disagreements measured above; the single-solid and node-count tests correctly still pass, since #699’s fix touches edges, not nodes, and never applies to a single-solid shape).

Every workflow action pin moves to a Node 24 major (#648)

#625 bumped only the job it introduced, so ci.yml was left mixed-version: actions/checkout@v7 in gate-scripts, actions/checkout@v4 in build-and-test and ios-simulator-build. Deprecation annotations are emitted per job, not per workflow, so each remaining v4 job raised its own “Node.js 20 is deprecated” warning.

All 17 pins across the five workflow files were enumerated rather than just the two the issue named: actions/checkout v4 → v7 (six sites), actions/cache v4 → v6 (four sites), and actions/github-script v7 → v9 (one site). maxim-lobanov/setup-xcode@v1 is unchanged, the moving v1 tag already resolves to a node24 build, as are gate-scripts’ own actions/checkout@v7 and actions/setup-python@v7.

Bumping actions/checkout alone would not have cleared the warning. The annotation on the base commit names two actions, not one, “the following actions target Node.js 20 … actions/cache@v4, actions/checkout@v4”, because it is emitted once per job listing every Node 20 action in that job. Changing only the action the issue names would have left actions/cache@v4 behind and the warning still standing, while the diff looked like a fix.

The target for each was read out of that action’s action.yml at the pinned ref, not inferred from the version number, and one action does not follow the pattern: actions/github-script@v7 is using: node20, and node24 only arrives at v8. A sweep that made every pin say v7 would have produced a repo that looked consistent and left require-issue-labels.yml deprecated, the same shape of defect as the mixed-version file it was fixing.

No bump changed an interface: action.yml at the new ref is byte-identical to the old one apart from the using: line in all three cases, so fetch-depth, submodules, persist-credentials and cache-hit keep their declared defaults. The two real behaviour changes are inert here, checkout v6 persists credentials to a separate file rather than .git/config (no workflow reads them or pushes), and checkout v7 blocks checking out a fork PR head under pull_request_target or workflow_run (neither trigger is used; the two pull_request workflows are unaffected).

Four gate scripts documented as gating on exit status, run by nothing (#625)

check-bridge-index.py, check-null-handle-guards.py, check-docs-defaults.py and count-operations.py were each written to gate a commit, and three said so in their own docstring. No workflow, hook, script or Makefile invoked any of them. CLAUDE.md told contributors the guard script “exits 1 on any unguarded site”, true, and it still merged green when it didn’t, because nothing ran it. The documentation described a gate that existed only as prose.

All four now run in a new gate-scripts job in .github/workflows/ci.yml, together with the three --self-test batteries, on ubuntu-latest, in about three seconds of work.

A separate job rather than a step in build-and-test, for a reason beyond speed. The obvious argument is that a pure-Python check should not wait ~6 minutes behind a Swift build to say a symbol name is misspelled. The load-bearing one is the status check: build-and-test is red branch-wide on any refactor/** branch carrying a kernel patch newer than Package.swift’s pinned xcframework (#585), so a gate folded into that job would be red for a reason that has nothing to do with it, indistinguishable from having no gate at all. Linux rather than macOS because none of the four needs Xcode, OCCT or a build, and a macOS runner bills ten times the minutes for the identical result. Python is pinned at 3.12 rather than taken from the image: these gates parse Swift and C++ with regexes, and a runner-image Python bump quietly changing a verdict is the exact failure mode they exist to prevent.

Each script’s --self-test runs alongside its real invocation. A detector that reports “all clear” because it is blind looks exactly like one reporting “all clear” because the tree is clean, and this branch shipped three gate scripts that were confidently wrong, #618 (the guard checker saw one of the five ways this bridge reaches a handle), #624/#630 (the index checker called seven correct entries misfiled), #626 (the drift it was written to catch was live in the tree). Two of those had self-tests with holes. Running the fixtures in CI is what keeps the gate from rotting into vacuity while still exiting 0.

Every step after the first carries if: '!cancelled()', so one failing gate does not hide the other three; without it a contributor fixes them one CI round trip at a time. !cancelled() rather than always() so the workflow’s cancel-in-progress concurrency still takes effect.

count-operations.py gains the docstring line its three siblings already had. It was always a gate, return 0 if (readme_n == derived and apiref_n == derived) else 1, but nothing said so, so it read as a release-time reporting tool, which is the only way it was ever used. Its counts had drifted on this branch before (#625’s own scope note).

It also now exits 2 on an unrecognised option instead of silently ignoring it. This one is a trap this PR itself arms: the other three gates all take --self-test, and CI pairs each self-test with its real run, so “seven invocations, self-test beside gate” becomes the house pattern in two places, and the natural thing to write when extending that list is count-operations.py --self-test, which was accepted, ran the ordinary report, and passed forever. The first fix was a docstring warning, which is the wrong shape of fix for an issue whose entire premise is that prose describing a gate is not a gate. Exit 2 matches the siblings’ “cannot run” status (they use it for a wrong working directory) so it is distinguishable from a real failure, and it also stops --fi silently reporting when --fix was meant.

An opt-in pre-commit hook, not an installed one. Scripts/git-hooks/pre-commit runs the same seven invocations locally, for the case CLAUDE.md actually addresses: a contributor mid-change, not a reviewer. It is enabled deliberately with a symlink into .git/hooks (or core.hooksPath, which replaces the hooks directory wholesale rather than adding to it, documented, because that silently disables any hook a contributor already has). The rejected alternative was auto-installation, a bootstrap script or SwiftPM plugin writing .git/hooks/pre-commit on first build. It was rejected because it changes when a contributor’s commits succeed without them asking, and its failure mode is a commit refused by a hook they did not know existed and cannot find in the tree. The hook runs the same seven invocations flag for flag. It first ran the three real gates with --quiet, which is exit-status-only, so it named the failing gate and then printed nothing about where, a fabricated index entry got you FAIL: check-bridge-index.py where CI names OCCTBridge.h:66. The flag was redundant anyway, since the runner already buffers output and only prints it on failure. The reason that survived the first round of proof is worth recording: the hook was proven with count-operations.py, the one invocation that had no --quiet and therefore the one case that could not exhibit the defect. A proof that exercises only the case immune to the bug is the same shape as the vacuous self-tests this batch keeps finding, the re-proof breaks all three of the previously---quiet gates individually and confirms each now prints its site.

Three ways the hook can still diverge from CI, all documented in it rather than closed: it checks the working tree rather than the staged snapshot, so a partially-staged commit can pass it and fail CI; it runs whatever python3 is on PATH while CI pins 3.12; and it warns and exits 0 if python3 is missing, so “the hook passed” can mean “the hook did nothing”. All three resolve the same way. CI decides, and it runs on every push regardless.

The install instruction was also wrong for this repo’s normal working mode: ln -s ... .git/hooks/pre-commit fails with “Not a directory” in a linked worktree, because a worktree’s .git is a file, and almost all work here happens in .claude/worktrees/*. The hook body was already worktree-correct (cd "$(git rev-parse --show-toplevel)"); only the instruction was not. Both the hook header and CLAUDE.md now give the worktree forms.

Verified by breaking each condition and confirming the gate catches it. Fabricating an index entry (OCCTShapeBoxNope) took check-bridge-index.py 0 → 1; deleting the IsNull() from a guarded bridge opener took check-null-handle-guards.py 0 → 1; editing a restated default in docs/reference/ took check-docs-defaults.py 0 → 1; editing README’s headline count took count-operations.py 0 → 1. All four returned to 0 on restore. The job is pinned to actions/checkout@v7 + actions/setup-python@v7 (both using: node24) rather than the @v4 its sibling jobs use, so it does not newly introduce a Node 20 deprecation annotation, those are emitted per job, so this job’s pins decide this job’s warning. Bumping the other two jobs is #648.

What this does not do: the repo has no branch protection and no rulesets, so a red gate-scripts is a visible red X that does not block a merge. Making it a required check is #649, and it is the one check in the repo that can be required without a caveat, unlike build-and-test it needs no OCCT, no build and no xcframework, so #585’s pinned-kernel mismatch cannot make it red.

Seven entry points indexed sub-shapes by occurrence while their consumers used the deduplicated map (#613)

#541 put sub-shape indexing on one enumeration, TopExp::MapShapes, one entry per distinct sub-shape, TopoDS_Shape::IsSame, orientation ignored. Seven entry points stayed on a bare TopExp_Explorer, which yields one entry per occurrence. A plain 10 mm box has 24 edge occurrences over 12 edges and 48 vertex occurrences over 8 vertices, because every edge is reached once per adjacent face, so the two enumerations disagree on any ordinary solid, not only on an exotic one.

The end-to-end failure is the composition this branch’s own documentation recommends. Shape.filleted(edges:radius:) carries the snippet bracket.filleted(edges: bracket.concaveEdges(), radius: 3). edgeConcavities() sizes its buffer from edgeCount and zips the bridge’s answer against edges(), but the bridge filled it per occurrence, so every label past the first repeat landed on the wrong edge. Measured on an L-bracket (two fused boxes, inner corner at x = 10, z = 10, map edge 27):

  before after
bracket.concaveEdges() [] [27]
edgeConcavityCount(.concave) 2 (occurrences of that one edge) 1
box.edgeConcavityCount(.convex) on a 12-edge box 24 12

So the recommended one-liner filleted an empty list. Measured on base, filleted(edges: [], radius: 2) returns nil, it reported failure, not success. The harm is not a silent wrong answer but a misattributed one: a nil from a fillet reads as “the fillet failed”, and nothing pointed at the edge selection as the cause. On the fix it returns a shape, 28034.3 mm³ against the bracket’s 28000.0.

The six named sites, plus one the issue and its audit both missed:

Site Was Measured divergence
OCCTShapeAnalyzeEdgeConcavity + OCCTShapeCountEdgeConcavity result-array order, and the count the table above
OCCTBRepExtremaExtCC the edgeIndex argument matches edges() to index 8, names a different edge from 9; answered for 12…23
checkSubShape (behind checkEdge/Wire/Shell/Vertex(at:)) the sub-shape index checkEdge(at: 12) reported a valid edge although edge(at: 12) is nil; checkVertex answered to index 47 on an 8-vertex box
OCCTLocOpeSplitShapeByVertex the edge-splitting index index 9 split edges()[4], index 11 split edges()[0]; 12 and 13 split successfully
OCCTShapeCreateMesh / …WithParams triangle faceIndex 12 indices emitted on an 11-face compound
edgesInFace(at:): and its unlisted sibling commonEdges(with:) the Edge.index handed back result-array positions: edgesInFace(at: 3) returned 0, 1, 2, 3 for edges at 2, 6, 10, 11, all four wrong, by 10.00, 12.25, 7.07 and 12.25 mm
OCCTBiTgteBlend / OCCTBiTgteBlendInfo_: named by neither the issue nor its audit a std::vector filled from an explorer, subscripted by the caller’s indices no index blended the bracket’s concave edge at all; and an unresolvable index was silently dropped rather than refusing the batch (#568)

Meshing is the one site where the two enumerations pull opposite ways, and it uses both. Triangle winding is set by the face’s orientation as it occurs in the parent (if REVERSED swap(n2, n3)), so converting it to the map would have kept only the orientation a shared face was first seen with, #614’s defect one level down. Measured on a BRepAlgoAPI_Splitter cut of a 20×10×10 block at x = 10: 12 face occurrences over 11 distinct faces, exactly one face present in both orientations, and the map stores it FORWARD. Meshed off the map the shared wall emits 2 triangles wound +x and 0 wound −x, the upper solid’s floor simply absent. The new occtForEachOrientedFace hands out both from one traversal: the occurrence for its winding, and that occurrence’s index in the enumeration faces() reads. FindIndex is the IsSame lookup, so a REVERSED occurrence resolves to its FORWARD twin’s index and both sides of a shared wall carry the one index that names it.

OCCTPolyMergeNodes is deliberately NOT converted, and is now documented and pinned as such. It emits no index, and the reversed flag it derives per occurrence goes straight to Poly_MergeNodesTool::AddTriangulation. Deduplicating it would add a shared wall once and lose the other solid’s side (measured: +x 2, −x 0). A regression test fails if a later sweep converts it.

The audit’s own verdicts were re-derived rather than taken on trust, and one was overturned: site 6 was recorded as “already on the map bridge-side”, which is true of the faceIndex argument and not of the Edge.index on the way out, the issue’s actual complaint, and a live defect. Sites 1-4 were confirmed safe to read off the map by measurement rather than by reading headers: every consumer was handed both orientations of every box edge (12 pairs) and vertex (8 pairs) and gave an identical answer, 0 differing, BRepOffset_Analyse::Type’s interval list, BRepExtrema_ExtCC’s IsParallel/NbExt/SquareDistance/ParameterOnE1/PointOnE1, the full BRepCheck_* status list, and BRep_Tool::Range + LocOpe_SplitShape::DescendantShapes. That matches what the headers predict (BRepOffset_Analyse.hxx:173, LocOpe_SplitShape.hxx:89-90, BiTgte_Blend.hxx:202 are all TopTools_ShapeMapHasher-keyed, and that hasher’s equality operator is IsSame, TopTools_ShapeMapHasher.hxx:35-38), but the headers are the reason to check, not the check.

The BRepCheck WIRE and SHELL spellings go through the same converted helper, and a plain box has no wire or shell occurring twice, so the first pass could not exercise them. Six further fixtures were built for exactly that, compound{solid, solid.Reversed()}, and the same for a shell, a face, a wire, an open (invalid, non-closed) wire, and a fused two-body solid, giving 26 WIRE pairs and 4 SHELL pairs, BRepCheck identical across orientation in every one, 0 differing, invalid geometry included. Their index domain does move, which is the point of the conversion and is recorded in SEMVER.md: on compound{solid, solid.Reversed()} the WIRE enumeration goes 12 occurrences → 6 distinct.

This was not a complete sweep of the per-occurrence idiom at the time. Shape.nbEdges / nbVertices / nbFaces returned per-occurrence counts (a box answered 24 and 48 against edgeCount 12 and vertexCount 8; a split compound’s nbFaces was 12 against faceCount 11) while their own published docs asserted the deduplicated answers, filed separately as #651 and resolved below, by deprecation rather than by repointing in place, since all three are pure duplicates of edgeCount/faceCount/vertexCount with no index or orientation dimension of their own. OCCTShapeFixEdgeSameParameter and OCCTShapeFixEdgeVertexTolerance also document “number of edges fixed” while counting explorer occurrences; that one is source-visible but unmeasured (a box returns 0 either way), so it is recorded here as a candidate rather than converted on a pattern match.

Bridge-only plus two Swift wrappers; no kernel patch and no OCCT.xcframework rebuild. OCCTLocOpeFindEdges and OCCTLocOpeFindEdgesInFace gained an optional outIndices parameter. Index-value changes are recorded in SEMVER.md. Tests: Tests/OCCTTopologyTests/Issue613IndexContractTests.swift and Tests/OCCTMeshTests/Issue613MeshIndexContractTests.swift, 25 tests, each proven to catch its own site by reverting that site alone.

nbEdges/nbFaces/nbVertices are deprecated in favour of the counters they duplicated (#651)

Shape.nbEdges, Shape.nbFaces and Shape.nbVertices counted bare TopExp_Explorer occurrences (OCCTShapeNbEdges/NbFaces/NbVertices, OCCTBridge_Topology.mm), the same gap #613 closed for its seven entry points, while this project’s own reference docs always documented the deduplicated answer: docs/reference/Document-Completions.md asserted box.nbEdges // 12 and box.nbVertices // 8, not the 24 and 48 the implementation actually returned. Measured on a plain 10 mm box:

  nbEdges edgeCount nbVertices vertexCount nbFaces faceCount
box 24 12 48 8 6 6

nbFaces agreed with faceCount on the box (no face is shared within one solid), and diverged only on a shape with a shared face: 12 against 11 on a two-solid split compound, reproducing #613’s own faceIndex measurement.

Confirmed by the Cluster A census (#664, Scripts/repro/cluster-a-subshape-enumeration/), run specifically to settle this question before any of #638/#642/#651 started: all three are pure occurrence duplicates of edgeCount/faceCount/vertexCount in every fixture measured, with no index, no orientation dimension, and no consumer that reads one and not the other. That is a different shape from #613’s seven sites, which addressed an index fed into another entry point and had no existing correctly-valued sibling to fall back on.

Decision: retire the duplicate spelling, not repoint it in place. Following the precedent #536 set for removeFeatures(faces:)/defeature(faces:) (two public names driving one operation, the newer forwarded and deprecated) rather than #541/#568/#613’s own precedent (repoint the raw value, because those sites had no existing sibling to rename to). Repointing nbEdges/nbFaces/nbVertices in place and keeping both names was considered and rejected: once the value agrees there is nothing left to distinguish the two spellings, which recreates the exact duplication #490/#491/#492 and #536 diagnosed elsewhere in this codebase.

All three are now @available(*, deprecated, renamed:), forwarding to edgeCount/faceCount/ vertexCount respectively, so the value is correct on the way out even though the spelling is retired. The now-orphaned bridge functions OCCTShapeNbEdges/OCCTShapeNbFaces/OCCTShapeNbVertices are deleted, following #506’s precedent for an orphan with no remaining Swift call site (OCCTBridge is a target, not a product, so nothing depends on the C symbol surviving). Recorded as a SemVer exception in SEMVER.md, since the returned value changes even though the build does not break.

Tests: Tests/OCCTTopologyTests/Issue651DeprecatedCounterTests.swift, pinning the corrected value against edgeCount/faceCount/vertexCount on a box and on a shape with a shared face, each proven to catch its own regression by reverting that one counter back to an occurrence walk.

docs/reference/Document-Completions.md, Edge.md, Selection.md and Shape-Features.md updated to match; docs/reference/Document-Completions.md was the page whose own asserted contract this issue was filed against.

The null-handle gate was blind to four of the five ways this bridge reaches a handle (#618)

Scripts/check-null-handle-guards.py printed “All bridge functions guard the geometry handle as well as the wrapper pointer” and exited 0. Its use-detector matched param->field and nothing else, so every site that reaches the handle through an indirection was invisible to it, and this bridge uses four: a cast (reinterpret_cast<OCCTSurface*>(ref)->surface, static_cast, (OCCTSurface*)ref), a pointer alias (auto* s = (OCCTSurface*)surface; ... s->surface), a handle alias (auto& surf = ...->surface;, and the by-value Handle(Geom_Surface) w = ...->surface; copy), and a shared bridge helper. Casts are now normalised away before the walk, aliases are followed, and the detector scores 6/6 on fixtures the old one scored 1/6 on.

The fourth form cuts the other way, and is why the fix is not just “teach it the cast spelling” (#624/#630’s lesson, one issue earlier: a sibling gate that had been taught indirection badly was confidently wrong about seven correct entries). OCCTGeomConvertCurveToAnalytical and occtSurfToAnaSurfResult hand their handle to occtCurveToAnalytical / occtSurfaceToAnalytical, both of which open with if (curve.IsNull()) return false;: checked, just one call frame away. A detector taught forms 1-3 and not form 4 reports both as defects. It now recognises a bridge helper that IsNull()-checks the parameter it is handed as a guard in its own right.

54 candidate (function, argument) pairs across 39 functions (the old detector found 1), of which 21 needed a guard and 33 were cleared by measurement. The issue’s own list of seven suspected-unguarded sites was partly wrong, and measuring first is what caught it: OCCTGeomLibToolParameter3D and OCCTGeomLibToolParameter2D reach GeomLib_Tool::Parameter, which returns false on a null handle, and OCCTApproxSameParameter reaches Approx_SameParameter, which raises a catchable Standard_Failure the function’s own catch (...) already turns into the same false a guard would return. Three of the seven needed nothing. The other four did, along with eleven sites the issue never named.

Guards added to 16 functions (21 (function, argument) pairs): OCCTLocalAnalysisCurveContinuity {,Flags}, OCCTLocalAnalysisSurfaceContinuity{,Flags}, OCCTSplitCurve3dContinuity, OCCTSplitCurve2dContinuity, OCCTConvertCurve2dToBezier, OCCTSplitSurface{Continuity,Angle,Area}, OCCTGeomTools{Curve,Curve2d,Surface}SetWrite, OCCTProjLibProjectOnSurface, OCCTGeomFillNSections{,Info}. Each returns the fallback its surrounding catch (...) already returns. The remaining 33 pairs (23 functions) are recorded in the script’s ALLOWED table, each with the measured reason it does not need one; four of those are not OCCT calls at all (OCCTBRepGraphRepSet* store into a bridge-owned side registry whose else branch deliberately stores a null handle to clear the slot, and BRepGraph_EditorView::SetPCurve documents the null handle as its clear-the-binding contract).

OCCTGeomToolsCurveSetWrite is the one guard here that measurement did not demand: GeomTools_CurveSet::Add guards its own handle, so a null cannot crash it. It is guarded anyway because the alternative was worse than noise. Add silently drops the null, Write() then emits a set with fewer curves than the caller passed, and Curve3D.serializeCurves/deserializeCurves is a round trip, so the surviving indices stop matching the input array. Guarding it also keeps the three identical GeomTools_*SetWrite writers from diverging three ways on null handling, which is the divergence shape this audit exists to remove.

Scripts/repro/556-null-handle-guard-sweep grew from 35 entry points to 57: the 22 the pre-#618 walk never reached, so nobody had measured them. 36 of 57 are now uncatchable signals (was 24 of 35). Along the way: GeomTools_CurveSet::Add guards with return (C.IsNull()) ? 0 : myMap.Add(C) while GeomTools_Curve2dSet and GeomTools_SurfaceSet contain no IsNull anywhere and both crash. An upstream inconsistency between three copies of the same writer.

The script also gains --self-test, matching check-bridge-index.py. It proves both failure modes by injection: six fixtures that must be reported (one per indirection form, including the plain param->field form, so the fix is provably additive) and six that must not (including a guard reached only through a by-value Handle copy, and one only through the shared helper). No public API change; behaviour changes only for inputs no bridge call can currently produce.

Breaking: continuityOrder is retired, because a warning did not stop the numbers changing underneath it (#619)

Source-breaking. Curve3D.continuityOrder, Curve2D.continuityOrder and Surface.surfaceContinuityOrder are now @available(*, unavailable). Any use is a compile error carrying the migration in the diagnostic.

Nothing about the values changes here. They already changed, in #485:

class before #485 now
C0 0 0
G1 −2 1
C1 1 2
G2 −3 3
C2 2 4
C3 3 5
CN 99 6

The old scheme was OCCTSwift’s own invention. It matched neither GeomAbs_Shape nor its own doc comment, and disagreed with the continuity property on the same curve for every class except C0. Reporting the real ordinal is right and is not reverted.

What #485 could not do with a deprecation attribute is stop the change being silent. The type stayed Int and the name stayed the same, so every call site kept compiling:

// Before: `2` was C2, so this rejected a C1 curve.
// After:  `2` is C1, so a merely tangent-continuous curve reaches a path assuming curvature.
if curve.continuityOrder >= 2 { useAsC2Spline() }

// And this, the analytic fast path, became unreachable rather than wrong, silently dead code.
if curve.continuityOrder == 99 { useAnalyticFastPath() }

A warning does not stop compilation, and neither outcome above is one a warning prevents: the first is a wrong geometric answer produced silently by a build that succeeded, the second is a branch that quietly stopped being taken. Retiring the spelling turns both lines into errors that name the old encoding, the new one, and the replacement.

There is no error sentinel any more, and that is its own migration hazard. The retired encoding signalled failure out of band, returning -1 from its default: branch and for a null or unreadable handle. continuity returns 0 in the same situations, and 0 is an ordinary C0 measurement. So if continuityOrder < 0 { handleError() } migrates to a branch that can never be taken, and an unreadable curve now reads as a genuinely C0 one. There is no in-band way to tell them apart, check the handle before asking.

Migration, both replacements predate this change and neither is new API:

// A continuity floor. Takes the request vocabulary by type, so the mismatched
// constant cannot be written at all.
if curve.continuityClass.satisfies(.c2) { useAsC2Spline() }

// The analytic fast path.
if curve.continuityClass == .cN { useAnalyticFastPath() }

// A raw ordinal, if that is genuinely what you want, but it is the *new*
// ordinal, so re-check the constant you compare against.
let ordinal = curve.continuity

continuity is unchanged in value: it read the real ordinal before the refactor and still does. Only its doc comment was wrong, which is what let the two spellings disagree unnoticed.

  • unavailable rather than deletion. The declaration stays so the compiler can explain itself; deleting it outright would say only “has no member continuityOrder”. Follows EvolvingFilletEdge.init(edgeIndex:) (#520), the same response to the same shape of hazard. The operation count drops by 3 (4,301 → 4,299), Scripts/count-operations.py does not count a retired spelling as a wrapped operation, and no new API was added.
  • Tests: Issue619ContinuityEncodingTests (OCCTCurveTests), Issue619Curve2DContinuityEncodingTests (OCCTGeom2dTests) and Issue619SurfaceContinuityEncodingTests (OCCTSurfaceTests) pin the encoding against geometry whose class is known by construction, and assert the trap is live: a C1 BSpline satisfies continuity >= 2 while continuityClass.satisfies(.c2) correctly refuses it. The #485 suites had their continuityOrder assertions moved onto continuity/continuityClass.
  • Recorded as a break in SEMVER.md.

The index gate was reporting seven correct entries, because it could not read a template helper (#624)

Scripts/check-bridge-index.py exited 1 with 0 stale, 7 misfiled, every one of them on the GCPnts_AbscissaPoint line of OCCTBridge.h’s cross-reference index, and every one reported as no bridge function reaches this class. The reading that fits, #477, #549, #600 and #603 did progressively move arc-length measurement off GCPnts_AbscissaPoint, is that the entries had drifted and needed re-filing. They had not.

Adjudicated per symbol, by reading each body and following the call path rather than the name:

symbol call path reaches
OCCTCurve3DGetLength* occtAdaptorArcLength / occtAdaptorLengthBetween → occtArcConvergedLength → occtArcQuadrature GCPnts_AbscissaPoint::Length
OCCTCurve3DParameterAtLength occtAdaptorParameterAtLength → occtArcWalkToLength GCPnts_AbscissaPoint (constructed)
OCCTCurve2DGetLength* same helpers, Geom2dAdaptor_Curve GCPnts_AbscissaPoint::Length
OCCTCurve2DParameterAtLength occtAdaptorParameterAtLength GCPnts_AbscissaPoint (constructed)
OCCTEdgeArcLength* same helpers, BRepAdaptor_Curve GCPnts_AbscissaPoint::Length
OCCTEdgeParameterAt* occtAdaptorParameterAtLength GCPnts_AbscissaPoint (constructed)
OCCTWireGetLength same helpers, BRepAdaptor_CompCurve GCPnts_AbscissaPoint::Length

All seven still reach the class. occtArcQuadrature (OCCTBridge_Internal.h) is singleSpan ? GCPnts_AbscissaPoint::Length(...) : CPnts_AbscissaPoint::Length(...), so #603 added a second class to these functions rather than replacing the first, which is what the index already said, on the CPnts_AbscissaPoint line directly beneath.

The defect was the checker’s. It classifies each brace-balanced definition by searching its signature for struct|class|union|namespace|enum, and a template <class TheAdaptor> head satisfies that, so the helper was filed as a type named TheAdaptor instead of as a function. A type is only ever reached by a function that names it, and none do, so the helper-indirection chain was cut at its first template link and the class looked unreached. The tell was that OCCTBridge_Modeling.mm’s one template <typename BoolOpT> helper parsed correctly: typename is not in that alternation, class is. without_template_head now strips the head before classification; a template <class T> struct Foo is still read as a type.

The nine arc-length helpers are what the misfiled entries pointed at, but they are not the whole of it. Every template <class ...> definition in the bridge was affected, 15 of them, filed under 6 bogus type names:

bogus type count helpers
TheAdaptor 9 occtArcQuadrature, occtArcConvergedLength, occtArcIntervals, occtAdaptorArcLength, occtArcWalkToLength, occtAdaptorParameterAtLength, occtConfineToDomain, occtAdaptorWindsPeriodically, occtAdaptorLengthBetween
AddEdge 2 occtFilletAddEdges, occtShapeFilletEdgeList
T 1 occtWriteKnotSplits
SplitIndexAt 1 occtWriteKnotSplitParams
Use 1 occtUseSubShapesByIndex
PointAt 1 occtFilletSetRadiusProfile

Across the whole bridge, 34 OCCT-prefixed functions’ reach sets change: 31 grow, and 3 shrink. The shrinking direction is the one that is easy to miss, the old parser was also false-widening. types['T'] was a real bucket holding everything occtWriteKnotSplits reaches, and the wrapper-type resolution step hands a function the contents of any type it names, so OCCTGeomFillGuideTrihedronACD0, OCCTGeomFillGuideTrihedronPlanD0 and OCCTWireGetCurvePointAt each inherited that bucket purely for containing a local identifier T. Fifteen junk names apiece. No index verdict depended on any of this, the run went 0 stale, 7 misfiled to 0 stale, 0 misfiled and nothing else moved, but a direction check is only as good as its reach sets, so the record should say what actually moved rather than only the part that was being complained about.

No bridge or Swift source changed, the index entry was right, and the note added to it records that the seven reach the class through the helpers rather than by naming it, so the next reader greps the right thing.

Two holes in the script’s own --self-test, both of which had to be closed for #625 to gate on it:

  • DIRECTION_TEST did prove the misfiled mode was caught, but all four of its cases named a single symbol, so the entry-level rule misfiled_entries explicitly warns against, “at least one of these reaches the class”, which lets one wrong symbol hide behind correct neighbours, passed the whole suite 16/16 when injected. A fifth case mixes a correct symbol with a wrong one and pins which symbol must be blamed.
  • INDIRECTION_TEST covered the plain inline helper but not the template one, so nothing failed when the parse above broke. A case for it now asserts the GCPnts_AbscissaPoint line stays clean.

Both were verified by injecting the regression they describe and confirming 17/18. The suite is 18/18 and the gate exits 0.

A single iso-row stops being documented, accepted, and then silently refused (#620)

Three layers disagreed about the minimum count Surface.drawMesh(uCount:vCount:) accepts. The doc comment said “at least 1”, the Swift guard (Sampling.gridTotal, default atLeast: 1) accepted 1, and the bridge returned 0 for anything under 2. The wrapper’s guard n == total turned that 0 into SurfaceGrid.empty, so drawMesh(uCount: 1, vCount: 20), in range by its own documentation, came back empty, and the caller had no way to tell “you asked for something unsupported” from “this surface has no mesh”.

The bridge was the layer that was wrong, which is not what the issue expected (“a mesh of one row has no quads”). Measured against the kernel first: despite the name OCCTSurfaceDrawMesh does not mesh anything. There is no BRepMesh, no triangulation and no quad, just a uniform walk of the sampled range calling Geom_Surface::D0, and a single (u, v) is a valid OCCT evaluation, a 1 × 20 iso-row off a sphere is 20 finite points. The 2 was never OCCT’s rule, it was this function’s own divisor: i / (uCount - 1) divides by zero at count 1. And the NaN that produces is worse than a throw would have been, because D0 does not throw on NaN, it returns NaN coordinates silently. Every other member of the same U-major grid family already accepted 1, OCCTSurfaceDrawGrid guards no count at all, EvaluateGrid and EvaluateGridD1 guard <= 0, so drawMesh was the family’s sole outlier. OCCTSurfaceDrawGrid, forty lines above in the same file, samples the same bounds and had spelled that divisor defensively since b1cd75d, the commit that introduced both functions. The bound moved to 1 rather than disappearing: below 1, and products past Sampling.maximumSampleCount, are still rejected at the Swift boundary before any allocation, which is #558’s contract unchanged.

That divisor is now occtUniformParameter in OCCTBridge_Internal.h, next to occtSurfaceGridIndex and for the same reason: it had been open-coded ten times in OCCTBridge_Surface.mm in four different spellings, and #620 is what a single copy written without the guard costs. Sharing the expression is what stops an eleventh loop re-deriving the unguarded form.

Nine of the ten are bit-identical substitutions. The tenth reassociates: the Gordon network builder’s f + (l - f) * ((double)j / (guideCount - 1)) becomes ((l - f) * j) / (guideCount - 1), and across ten realistic [FirstParameter, LastParameter] ranges 25-33% of cases differ by 1-2 ulp (≤ 4e-15 over the whole span, exactly 0 on this repo’s own Gordon fixture). One structural consequence beyond the magnitude: the old form always landed the last sample exactly on f + (l - f), while the new one can land 1 ulp past LastParameter (4 cases of n = 2…60 on a 0..2π profile). Harmless here, because the builder SetNotPeriodic()s the curves first so Geom_BSplineCurve::D0 does not throw just outside the range, but it is a boundary the old expression structurally could not cross, so it is recorded at the site rather than left for someone to rediscover.

Three open-coded lines remain, across two sites, both deliberately. OCCTGeomFillAppSurf’s (double)i / (count - 1) is unguarded: the exact #620 shape, but chasing it turned up a separate, larger defect (Surface.appSurf(curves:) segfaults on a single curve regardless of the parameter value, so it is a missing arity guard rather than a divisor bug), which is filed on its own; converting the divisor would not fix it and would muddy that fix. OCCTGeomFillCoonsPatchEval’s two lines are a different contract, not the same expression: their single-sample branch is 0.5, the patch midpoint, where every other site’s is the low end. The helper’s own doc says so, so the next sweep does not fold them in on shape alone.

Recorded, not fixed: the Gordon family has no behavioural test coverage at all. All five Gordon tests assert only nil-ness and status ordinals, not one checks a point, pole, degree or bound, and networkSurfaceBuildsOrReportsStatus, the only test reaching the changed line, accepts any status but .notStarted; on the repo’s own makeNetwork() fixture the builder returns KnotAlignmentFailed, so it never builds a surface. Nothing in the repo would have caught the 1-ulp change above, or one many orders of magnitude larger. Filed separately.

A second wrong claim, caught reviewing the first fix. The new contract sentence said uCount: 1 is “the single iso-row at uMin”. That holds only for a bounded surface: the bridge clamps infinite bounds to ±100 before deriving parameters, so on a plane domain.uMin is about -2e100 while the single row sits at -100. Same defect class as #620 itself, a claim true of the fixture in front of you and false in general, so the docs now name the sampled range (the domain, with infinite bounds clamped) rather than the domain, and a test pins the clamped value on an unbounded surface. The first version of that test checked only finiteness, which passes under either reading and so could not contradict the doc.

The sibling site keeps its 2. OCCTSurfaceCreateBezier carries a visually identical uCount < 2 || vCount < 2, and that one is the kernel’s: a Bezier’s degree is its pole count minus 1 and must be at least 1, so Geom_BezierSurface raises Standard_ConstructionError on a single-pole direction (measured; 2 × 2 builds a bilinear patch). Surface.bezier(poles:weights:) already guarded the same bound, so that site had no three-layer mismatch at all, only a doc that was silent about the bound rather than wrong about it. Both look-alike guards now say in a comment which kind they are, so the two are not “made consistent” by a later sweep. The accompanying test pins the public contract only, and says so: relaxing the bridge guard alone leaves it passing, because Surface.bezier rejects in Swift and never reaches the bridge, and if it did, OCCT would throw and catch (...) would return nullptr, so nil comes back either way. No black-box test can separate those two layers, and claiming otherwise would be the same kind of overstatement this entry is about.

The reference page that kept documenting the continuity default #491 replaced (#626)

Two pages under docs/reference/ restate Surface.approxWithDetails. #491 flipped both of its continuity defaults from C1 to C2, so that it and Surface.approximated stop fitting to different smoothness when neither is given a continuity argument, and updated Surface.md. Shape-HLR-Geom.md went on declaring uContinuity: ParametricContinuity = .c1, vContinuity: .c1.

That page is in this branch’s changed set: its quasiUniformParameters entry was rewritten thirty lines below the stale default. A reader following it and omitting the arguments expects a C1 fit and gets a C2 one, which per #572 is not cosmetic, continuity is one of the inputs deciding how far the fitted surface moves. It also reinstated, in the documentation layer, the exact divergence #491 existed to remove.

The interesting part is not the one-line correction. A per-type reference page that restates a signature is a copy, and this tree holds 3427 such restatements. Scripts/check-docs-defaults.py parses every one of them, matches it to its declaration in Sources/OCCTSwift, and compares them position by position: 1460 defaults compared, 2 drifted, both of them this one.

A default drifts in three shapes, and all three fail the run, because a gate that reports a defect while exiting 0 is not a gate. The literals can differ. The docs can state a default the source does not have, so a required argument reads as optional. Or the source can state one the docs omit , which is also how a source-side addition hides behind an unchanged page, and it is the reason the comparison covers restatements carrying no defaults at all rather than only the 876 that do. (--lenient drops that third shape to a warning, for a tree still paying it down; the other two fail either way.) Layout is not drift: SIMD3(0, 0, 1) and SIMD3(0,0,1) compare equal.

All of that depends on comparing against the right declaration, because several share one name and label list: writeOBJ(to:deflection:) is both Document.writeOBJ (deflection 1.0) and Shape.writeOBJ (0.1). Resolving that by accepting agreement with any candidate cannot invent a failure, but it can swallow one, and it did, twice, at two different depths. First across types, letting a page state Shape.writeOBJ’s default for Document.writeOBJ and exit 0. Then, once a hint selected the right type, across the overloads within it: deprecating a method by keeping its old signature verbatim, defaults included, is the ordinary way to deprecate, and the stale page went on matching the retained twin. Both are the #626 shape walking through the gate built for it.

So the owning type is resolved from the nearest heading’s qualifier, then outward through the enclosing section headings (CurveAdaptors.md holds ## WireCurve and ## EdgeCurve, each with an identically titled ### points(count:)), then the page filename, and where that still leaves two candidates disagreeing about a default, the restatement is reported as unverified and fails, rather than being quietly decided by whichever one happened to match. A twin that merely lacks a default is still disambiguated by the doc’s own defaults, so the ordinary deprecation shape stays quiet. On this tree: 2713 resolved uniquely, 504 by heading, 163 by filename, and 0 unverified.

Worth stating exactly, rather than leaving it implied. 129 doc sites across 67 signature groups still hold more than one candidate after narrowing, but 108 of those are skipped upstream, neither side states a default, so there is nothing to compare, and only 21 sites across 9 groups reach the ambiguity guard at all. Of those, 0 disagree on a value, 4 have a twin merely lacking one, and 17 agree outright; the 17 are protected by the rule rather than by luck, since any later divergence between them reports unverified. The carve-out is narrower than “safe”: a single-sided acquisition, one overload gains a default, a bare twin remains, and the page states none, stays quiet. That is the mirror of the documented twin exemption, it can only ever miss a source_only -class defect, and it is strictly better than before this change, when all 108 were not examined at all.

Exit status is 1 on any drift, on an unverified restatement, or on growth in the unmatched bucket, a restatement stops being compared the moment its labels stop matching, so an unpinned bucket there would absorb a rename silently. --self-test runs a 13-case battery in memory, covering each shape above and each mechanism the gate depends on; every case was checked by reverting the mechanism it guards and confirming the battery goes red, because a case that passes with its subject removed is not a test. It is committed because three gate scripts on this branch have now shipped confidently wrong, and an uncommitted battery regresses without saying so. Nothing runs this script yet, wiring the gate scripts into CI is #625, whose own note warns against installing a gate that passes unconditionally.

ContinuityClass.isParametric was flagged as the same root cause and is the same shape of miss. #623 fixed satisfies(_:) and gave derivativeOrder an explicit warning that its nil means “no parametric order”, not “meets no floor”, because guard let o = derivativeOrder else { return false } reproduces #623 verbatim. isParametric is that property’s structural sibling with the identical trap, it is false for the geometric classes, so guard measured.isParametric ahead of a .c0 check reports a tangent-continuous curve as not even connected, and the warning was added to only one of the two. It now carries it as well, pointing at satisfies(_:) the same way.

The one grid layout finally covers the third type holding a grid (#617)

#486 declared U-major (occtSurfaceGridIndex, iu * vCount + iv) THE surface-grid buffer layout of this codebase, and gave SurfaceGrid / SurfaceGridD1 an .at(u:v:) accessor for it, precisely so two bridge functions could not go on writing opposite layouts while each header called its own “row-major”. A third type holding the same shape of buffer was left out of that sweep.

BRepGraph.FaceGridSample hands back four parallel flat buffers (positions, normals, Gaussian and mean curvature) that OCCTBRepGraphSampleFaceUVGrid wrote transposed, iv * uSamples + iu. The Swift type documented no layout and offered no accessor, so the only guidance a caller had was the convention #486 had just declared, which was the wrong one for this one type.

Two things make this worse than a plain inconsistency. docs/reference/BRepGraph-Editor-Identity.md already documented the U-major index (index = u * vSamples + v) for this type, so the bridge was contradicting its own published reference page, not just a sibling type. And the failure is not uniform: a caller reading with the layout-conforming stride gets a silent wrong answer at every aspect ratio (a normal or a curvature attributed to the wrong place on the face, no trap), while the neighbouring slip of using the wrong count as the stride (u * uSamples + v) stays in range on a 3×10 grid and runs off the end of the same 30-element buffer on a 10×3 one. #617’s own report has these two expressions crossed; the arithmetic is now pinned in a test.

Converted the bridge to U-major rather than documenting the transpose, so the codebase keeps one rule, and routed it through the shared occtSurfaceGridIndex so the formula is not re-spelled at a third site. FaceGridSample gains the .at(u:v:) accessor its siblings have, resolving the index through the same shared Swift-side surfaceGridIndex, plus an explicit layout line and worked snippets on the type, the method and the reference page. OCCTBridge.h’s declaration now states the literal index instead of being silent.

No consumer depended on the old order. Every in-repo caller and all three ecosystem callers (PadCAMEngine’s PadCAMMLExport, OCCTSwiftScripts’ occtkit graph-ml and GraphML) sample a square grid and either map the arrays element-wise into a JSON payload or reduce them order-independently (a mean position/normal face signature), so none reads a specific (u, v). The observable change is limited to the order of the flat arrays those exporters serialize, which was never a documented contract on the emitted payload.

API Before After
OCCTBRepGraphSampleFaceUVGrid buffers V-major, iv * uSamples + iu, layout undocumented U-major via occtSurfaceGridIndex, index stated in the header
BRepGraph.FaceGridSample four flat arrays, no layout doc, no accessor same arrays documented U-major, plus at(u:v:)

A tangent-continuous surface stops being reported as not even connected (#623)

ContinuityClass offers two ways to ask “is this at least X”, and they disagreed. satisfies(_:) short-circuited on the nil derivativeOrder that the geometric classes carry, returning false before it ever read the requested floor:

surface.continuityClass                  // .g1
surface.continuityClass.satisfies(.c0)   // false
surface.continuityClass >= .c0           // true

So a caller gating on “is this at least positionally continuous?” through the API the docs steer them to rejected every G1 and G2 result, surfaces smoother than the C0 ones it accepted.

The old justification, “a g1/g2 result is not a parametric guarantee at any order”, is right for C1 and above and wrong for C0. G1 entails G0 entails positional continuity, and positional continuity is exactly what C0 is; there is no parametrisation subtlety at order zero, a curve is either connected or it is not. The nil branch now floors at order 0 instead of failing unconditionally, so .g1/.g2 satisfy .c0 and still correctly refuse .c1 and above. derivativeOrder is unchanged, it still reports nil, because a geometric class genuinely has no parametric order; the floor lives in satisfies alone.

Sweeping the full 7×7 matrix of satisfies against >= found one more disagreement than the reported cell, and it is not a bug. Of the 49 (measured, required) pairs, 28 name a ParametricContinuity floor that both APIs can answer. Before: three disagreed, (.g1, .c0), (.g2, .c0) and (.g2, .c1). After: exactly one, (.g2, .c1), and it is correct. GeomAbs_Shape ranks G2 (3) above C1 (2), but curvature continuity does not entail first-derivative continuity, so the parametric floor rightly refuses what the ladder allows. That is a genuine difference in what the two APIs are for, not drift, and both doc comments now say so: satisfies(_:) tests a measured class against a requested parametric floor, </>= ranks two measured classes by their place in the ladder, and outranking is not entailing. The one exception is named in both.

A third contract in the same file, ContinuityAnalysis.holds(_:) and the GeomAbs_Shape-ordinal junction-analysis bitmask behind it, asks exact-class membership rather than a floor or a ranking and is deliberately untouched; a regression test pins that it stayed independent.

Both readings are OCCT’s own, not an inference: dox/user_guides/modeling_data/modeling_data.md says at line 1281 that C0 “is the same as G0 (geometric continuity), so the last one is not represented by separate variable”, and at line 1289 that “Geometric continuity (G1, G2) means that the curve can be reparametrized to have parametric (C1, C2) continuity”. The first is why a geometric class clears the C0 floor; the second is why it clears nothing above it, since the existence of a reparametrisation is not a promise about the parametrisation in hand. Both are now quoted in the satisfies(_:) doc, and derivativeOrder carries an explicit warning that its nil means “no parametric order”, not “meets no floor”, the hand-rolled guard let o = derivativeOrder else { return false } reproduces #623 verbatim.

Issue623ContinuityFloorTests (Tests/OCCTSurfaceTests/) carries the matrix sweep plus two monotonicity directions, and the tests are explicit about which of them actually guard this bug. Monotonicity in the requested order (whatever a class satisfies, it satisfies everything weaker) holds for any implementation shaped f(measured) >= required.rawValue, the buggy one included, so it guards a future rewrite that loses downward closure rather than a regression here. Monotonicity in the measured class (if a weaker measurement clears a floor, a higher-ranked one should too) is the invariant the unconditional false actually broke, and it does fail under the injected bug, 4 violations against the fixed code’s 1, that 1 being the documented G2/C1 cell, which violates it too and so is pinned rather than asserted away. The soundness direction (a floor check may be stricter than the ladder, never looser) passes vacuously under a too-strict implementation and guards the opposite failure mode: an over-correction that has a geometric class clear a floor the ladder never reaches. The matrix is what would have caught this; the single cell would not have. Issue485SurfaceContinuityTests had pinned the old answer as correct and is corrected.

Breaking: the nearest point on a curve, for the entry points #539 left behind, and the whole 2D side (#615)

#539 established that GeomAPI_ProjectPointOnCurve::LowerDistance() reports an extremum, which on a bounded curve is neither necessarily the nearest point nor necessarily present at all, and introduced occtNearestPointOnCurveRange, the minimum over ShapeAnalysis_Curve, every extremum in range, and both curve ends. Three entry points were converted. The shared helper behind three more was not, and its 2D twin was never touched.

So the two spellings of one question disagreed. Measured through the public Swift API on #539’s own repro geometry, a half circle of radius 5 over [0, π] queried from below at (0, -6, 0):

  before after
Curve3D.projectPoint (converted by #539) param 0, distance 7.8102 unchanged
Curve3D.nearestParameter param π/2, distance 11 param 0, distance 7.8102
Curve3D.nearestParameter, point past a [3, 8] segment’s end nil param 8, distance 92
Curve2D.project(point:) point (0, 5), distance 11 point (5, 0), distance 7.8102
Curve2D.project(point:), past the end nil param 8, distance 92
Point2D.distance(to:), past the end .infinity 92

A caller seeding a trim or a split from nearestParameter landed on the opposite side of the arc from where projectPoint said the nearest point was. The two spellings disagreed about which point is nearest and about whether there is one.

Both defects, both dimensions, one helper each. occtNearestProjectionOnCurve3d now routes through occtNearestPointOnCurveRange; the new occtNearestPointOnCurve2dRange gives the 2D side the equivalent treatment.

The 2D candidate set is the extrema plus both ends, and cannot be more. ShapeAnalysis_Curve has no 2D projection, its Project overloads take Geom_Curve or Adaptor3d_Curve only, and its Geom2d_Curve members (FillBndBox, SelectForwardSeam, GetSamplePoints, IsPeriodic) do something else entirely. That is the “all extrema + the two ends” row #580 measured at 188/189 rather than the 189/189 the third source buys; the one case it misses is Extrema failing to converge on a BSpline, where an end then wins by a fraction of a percent, and there is no 2D-native second algorithm to break that tie. (A Geom2d_Curve could in principle be lifted into the z = 0 plane and run through the 3D ShapeAnalysis_Curve; not done, since that buys one case in 189 at the cost of a per-call curve conversion.)

Breaking: nil no longer means “no perpendicular foot”. A point past the end of a bounded curve is nearest to that end, and a circle’s centre is equidistant from every point on it; all of these now answer with a real parameter and a true distance. nil (and Point2D.distance(to:)’s .infinity) is left meaning what it means for the entry points #539 converted: no curve to answer about. Affects Curve3D.nearestParameter(to:), Curve2D.nearestParameter(to:), Curve2D.project(point:), Curve2D.project(_:), Point2D.distance(to:) and the deprecated parameterAtPoint/closestParameter spellings, which no longer have a reachable .nan case on a real curve. They stay deprecated for the reason they always were: no Double can carry a failure signal, because every value is a legitimate parameter on some curve.

Curve2D.allProjections(of:) still reports nothing where the other four now answer, and that is correct. It asks for the extrema, which has been a different question since #539, and on a bounded curve queried from beyond its end the honest answer is that there are none. Before #615 all five agreed only because the other four were asking the extrema question too.

Curve3D.locateNearestPoint: the fallback changed, the primary search deliberately did not. The primary reports the lowest-distance extremum inside a ±10% window around initParam. The guess bounds the window; it does not rank what is found in it, so the extremum returned is not necessarily the one nearest the guess, measured on a ramped sine BSpline, a guess of 90.9114 returns param 79.9751, 10.94 away, over an extremum 0.13 away at 91.0378, because the far one is closer to the query point (10.07 against 15.19); 22 of 46 multi-extremum windows behave so. The window is what makes the answer local, and a windowed minimum can still be a global maximum: with a guess of π/2 on the arc above it reports 11, and that is pinned by test.

Adding the window’s two ends to that minimum was considered and rejected, not because it would redefine initParam, which it would not, since the function already minimises over the window and the ends are all the change adds. It was rejected because (1) it does not make the function correct under its own name, answering 10.865697905689686 where the true nearest point is 7.8102 away, and (2) a window’s ends always evaluate, so the minimum would always be found and the fallback would become unreachable, deleting the one path in this function that #615 fixes. Making the search global outright would leave initParam meaning nothing and the function a duplicate of nearestParameter.

The full-range fallback is a different matter: it fires only when the window holds no extremum, at which point the function has already abandoned locality, so it must give the whole curve’s answer. Measured, a guess of 0, sitting on the true nearest point, used to fall through and return the point diametrically opposite it (π/2, distance 11); it now returns 0 at 7.8102. A [3, 8] segment queried at (100, 0, 0) returned nil for every guess and now returns param 8, distance 92.

Bridge-only: no kernel patch, no OCCT.xcframework rebuild. Regression suites Issue615NearestParameterRangeTests (OCCTCurveTests) and Issue615Curve2DNearestPointTests (OCCTGeom2dTests), including a 2D-vs-3D cross-check on the same geometry in the z = 0 plane, the comparison neither side had, and the reason the 2D defect survived #539. Proved by injection: restoring both LowerDistance helper bodies fails 17 of the 32 tests across the five affected suites with 41 issues, and the test pinning the preserved windowed primary path keeps passing, as it must. The 2D sweep’s ground-truth anchor was separately proved non-vacuous by over-reporting the distance in the helper: 9 failures with abs(), 0 without it.

The 21 result-buffer capacities #558’s sweep never reached (#622)

#558 bounded 28 sampling entry points and introduced Sampling.capacity(_:) / Sampling.requested(_:atLeast:). A review found Shape.raycast(origin:direction:tolerance:maxHits:) with the identical footgun and named three more sites. Re-running #558’s own measurement against the current tree found 21, not 4, and two of the four the review named do not survive it at all: MedialAxis.drawArc was already bounded by #558 itself, and both quoted Document.swift line numbers land on unrelated code. The named list was neither complete nor correct, which is the same lesson #558 recorded when its census said 14 and its measurement said 28.

The reason the sweep stopped short is a scoping one, not an oversight: #558 scoped itself to samplers, and these 21 are result-buffer capacities on picking, spatial search, projection, intersection, hatching, text conversion and directory listing. Same mechanism throughout, a caller-supplied number sizes a Swift allocation and is then cast to the int32_t the bridge takes its capacity in, so Array(repeating:count:) traps on a negative and Int32(_:) traps past Int32.max.

Measured one case per process, since a trap takes the whole harness down with it. 18 of the 21 are drivable from a standalone binary; all 18 failed at Int(Int32.max) + 1, 2 aborted immediately with Fatal error: Not enough memory and 16 ground past a 40-second timeout on an allocation nothing can serve. 12 of the 18 also aborted on -1, inside Array(repeating:count:), before any bound could be consulted. The remaining 3 (Selector.pick’s three overloads) need a live selector and were converted on inspection, then covered in-process. After the fix all 18 return their documented value at both inputs.

The sites, by owning type: Shape.raycast, Shape.allDistanceSolutions, Shape.selfIntersectionPairs, Curve3D.extrema / .intersections(with:maxHits:) / .splitAtContinuity / .projectPointAll, Curve2D.splitAtContinuity, Surface.intersections(with:maxCurves:) / .projectPointAll, KDTree.kNearest / .rangeSearch / .boxSearch, Selector.pick (all three overloads), HatchPattern.generate, UnicodeUtils.convertFromUnicode, DirectoryIterator.list, FileIterator.list, and LogSample.sample.

The contract is #558’s, not a fifth one. 20 of the 21 are capacities: the algorithm decides how many results exist and the number only truncates, so they clamp into 0...Sampling.maximumSampleCount and an absurd capacity returns the same answer rather than an empty one. The exception is LogSample.sample(from:to:count:), whose bridge fills the buffer exactly, that is a request, so it rejects outside 1...ceiling rather than silently handing back 10 million values for a 10-billion request. Exactly the split #558 drew between Curve3D.drawAdaptive and MedialAxis.drawArc.

let box = Shape.box(width: 10, height: 10, depth: 10)!
let ray = (origin: SIMD3(0.0, 0, 20), direction: SIMD3(0.0, 0, -1))

// A capacity is clamped, so an unservable one still returns the real answer.
box.raycast(origin: ray.origin, direction: ray.direction, maxHits: 10_000_000_000).count
    == box.raycast(origin: ray.origin, direction: ray.direction).count   // true
box.raycast(origin: ray.origin, direction: ray.direction, maxHits: -1)   // [] -- used to abort

// A request is rejected, never silently coarsened.
LogSample.sample(from: 1, to: 100, count: 16).count                      // 16
LogSample.sample(from: 1, to: 100, count: 10_000_001).count              // 0, not 10,000,000

Two behaviour changes beyond “stops aborting”, both at a zero-or-negative count:

  • Shape.allDistanceSolutions(to:maxSolutions:) returns [] rather than nil for a capacity of 0 or less. The bridge answers -1 for maxSolutions <= 0 and the old guard count >= 0 turned that into nil, so “no room was offered” was reported as “the measurement failed”. Every sibling in this set returns its documented empty value for no capacity, and nil is now reserved for an actual failure.
  • KDTree’s three searches gain an effective result ceiling of 10,000,000. The bridge returns min(results.Size(), maxResults) with no truncation flag, so a caller who previously passed a huge capacity against a >10-million-point cloud and got a complete answer now silently gets 10,000,000 of them. The silence is pre-existing (there was never a “there were more” signal); the ceiling is new.

A separate, pre-existing defect surfaced while building the fixture and is not fixed here: Curve3D.extrema(with:maxCount:) SIGSEGVs (uncatchable) on two parallel curves at any capacity, measured at maxCount 2, 20, 100, 1e4, 1e6, 1e7 and Int32.max + 1, all signal 11, including the method’s own default of 20. The bridge’s own loop is correctly bounded by min(NbExtrema(), maxCount), so the crash is inside GeomAPI_ExtremaCurveCurve’s construction: the documented BRepExtrema_ExtCC parallel hazard, on the GeomAPI path. It has nothing to do with the count bound, and the regression fixture uses skew curves deliberately so that it tests the bound rather than the crash. Filed as #636.

Also measured, also not fixed here, for the same “do not invent a contract” reason, now filed as #640. The math solver/optimizer family has the same trapping shape, at 13 entry points in Sources/OCCTSwift/Document.swift: MathSVD.solve (:5699), MathJacobi.eigenvalues (:5744), MathHouseholder.solve (:5919), MathOptimizer’s solveSystem, minimize, minimizePowell, particleSwarm, globalMinimize, solveSystemNewton, minimizeNewton and gaussSetIntegration, and MathSolver.leastSquares (:14193) and .uzawa.

These are problem dimensions that must agree with the caller’s own matrix / startPoint arrays, not sampling capacities, and clamping a dimension to 10,000,000 would hand back a garbage-dimension solve rather than truncating a result set, so Sampling.* is the wrong tool and applying it would have been precisely the fifth behaviour #622 asked to avoid.

A consistency check against those arrays is not on its own the remedy, which is where an earlier draft of this note was wrong. A degenerate array satisfies the consistency relation exactly and still aborts:

MathJacobi.eigenvalues(matrix: [1.0], n: -1)   // matrix.count == n*n holds exactly: 1 == 1
                                                // still aborts: Can't construct Array with count < 0

The same holds for MathSVD.solve and MathHouseholder.solve. A positivity bound is needed as well. And MathSolver.leastSquares has no consistency check at all, so Int32(rows) / Int32(cols) drive an out-of-bounds read of matrix inside the bridge, memory unsafety rather than a clean trap, and the reason #640 is not merely a tidiness issue.

Regression suite: Tests/OCCTMiscTests/Issue622AllocationBoundsTests.swift, 13 tests covering all 21 entry points. They run in-process only because the fix is what lets them: before it, every assertion in them would have aborted the test harness rather than failing. They compare .count == 0 rather than reading .isEmpty, for the reason #558 recorded. Swift Testing prints the captured sub-expression on failure, and a regression returning 10 million results would print all 10 million of them.

Confirmed by injection, one bound at a time: with the ceiling removed from the bound it covers (and the lower bound left in place, so the edit still compiles and the injection is exactly “the ceiling is gone”), all 13 tests stopped passing, 12 aborting with signal 5 and raycast dying mid-run before it could report an assertion. Each was restored by exact reverse replacement and re-verified afterwards.

The raycast case dies at the Int32(capacity) conversion (“Not enough bits to represent the passed value”), not at the buffer allocation: calloc hands back lazily-zeroed pages, so the nominally ~189 GB [OCCTRayHit] costs nothing until it is touched. The trapping conversion is reached first. Worth recording, because it means the allocation size is the wrong thing to reason about when judging which of these entry points is dangerous, the int32_t cast is what fires, and it fires identically whether the element is 8 bytes or 88.

The math dimension family traps on a consistent-but-negative dimension, and one site reads out of bounds (#640)

#622’s own writeup deliberately left the math solver/optimizer family unfixed: its numbers are problem dimensions that must agree with the caller’s own arrays, not sampling capacities, so Sampling.requested/capacity is the wrong tool and clamping one would hand back a garbage-dimension solve. That premise is correct. The remedy it suggested, a consistency check against the caller’s arrays, is not, measured:

MathJacobi.eigenvalues(matrix: [1.0], n: -1)
// matrix.count == n * n holds exactly: 1 == (-1) * (-1)
// still aborts: Fatal error: Can't construct Array with count < 0

A consistency check alone cannot exclude this shape: whenever one factor of a product is zero, the other is unconstrained, so rows: 0, cols: -1 (or the symmetric n: -1 case above, where matrix.count is 1 either way) satisfies matrix.count == rows * cols for any value of the other factor. MathSVD.solve and MathHouseholder.solve have the identical gap. A positivity bound closes it, alongside the consistency check, not instead of it.

MathSolver.leastSquares is worse: it had no consistency check at all, so a rows/cols pair that is positive but does not match matrix/rhs’s real length sails straight through and reaches the bridge’s matA[i*nCols+j]/b[i] loops, which read unconditionally. That is an out-of-bounds read, not a trap: confirmed in a standalone process, rows: 1000, cols: 1000 against a 1-element matrix crashes with Bus error: 10; smaller mismatches silently returned nil from a garbage-fed IsDone() instead, never a reliable failure either way.

Re-deriving the census by reading each candidate bridge function, rather than trusting the trap-shaped count either side had produced, found 20 affected entry points, not 13. #634’s own writeup had already grown the count from 10 to 13 under review (MathSVD.solve, MathJacobi. eigenvalues, MathHouseholder.solve were unnamed anywhere until then); measuring by allocation shape and by trapping Int32(_:) conversion, as suggested, reproduces that 13. A third lens, does a bridge function index a raw pointer up to a caller-supplied count with no check that the backing Swift array is actually that long, found five more, invisible to both prior lenses because none of them constructs a [Double] sized by the vulnerable dimension:

  • MathGauss.determinant / MathCrout.determinant return a scalar Double; the unconditional matrixData[i*n+j] loop is exactly leastSquares’s shape, just with nothing to trap on. Both crash with Bus error: 10 at n: 1000 against a 1-element matrix, confirmed in a standalone process.
  • MathSolver.eigenvalues(diagonal:subdiagonal:) / .eigenvaluesAndVectors derive their dimension from diagonal.count (never negative), but read subdiagonal[i] for i in 0..<diagonal.count with no check that subdiagonal is that long: the /// comment already said “must be same length”; it was never enforced. Measured: a 50-element diagonal against a 1-element subdiagonal returns eigenvalues up to 1.17e+131, silently.
  • MathSolver.gaussMultipleIntegration derives nVars from lower.count and reads upper[i]/order[i] up to it the same unguarded way. .gaussSetIntegration has both this gap and the trapping one (nEquations sizes its result array).

All 20 now require the dimension argument to be positive and every array it indexes into to match it exactly, closing the trap and the read together:

site fix
MathGauss.determinant, MathCrout.determinant n > 0, matrix.count == n * n
MathSVD.solve, MathHouseholder.solve rows > 0, cols > 0 added to the existing consistency check
MathJacobi.eigenvalues n > 0 added to the existing consistency check
MathSolver.solveSystem, .solveSystemNewton variables > 0, equations > 0, startPoint.count == variables
MathSolver.minimize, .minimizePowell, .minimizeNewton variables > 0, startPoint.count == variables
MathSolver.particleSwarm variables > 0, lower/upper/steps.count == variables`
MathSolver.globalMinimize variables > 0, lower/upper.count == variables`
MathSolver.leastSquares rows > 0, cols > 0, matrix.count == rows * cols, rhs.count == rows (new: had nothing)
MathSolver.uzawa nConstraints > 0, nVars > 0, and all three arrays checked against them (new: had nothing)
MathSolver.eigenvalues, .eigenvaluesAndVectors subdiagonal.count == diagonal.count (new)
MathSolver.gaussMultipleIntegration upper.count == lower.count, order.count == lower.count (new)
MathSolver.gaussSetIntegration nEquations > 0 plus the same array checks as gaussMultipleIntegration

Also in scope, reached by a different lens: findAllRoots(samples:) (both overloads) is a sampler by name and by role, not a problem dimension, despite superficially trapping the same Int32(_:) way. It is routed through Sampling.requested instead, matching #558’s own family, and rejects with [] rather than trapping past Int32.max. Checked alongside it and confirmed correctly excluded: kronrodIntegrate/kronrodIntegrateAdaptive/integGauss/integKronrod/ integKronrodAdaptive’s points/gaussPoints parameters trap the identical way at Int(Int32.max) + 1, but select a quadrature rule order inside OCCT: no bridge function indexes an array by them, and a negative value is rejected cleanly via IsDone() == false, measured, not assumed. #634’s “kernel-side, not a Swift buffer” reasoning holds for these; it did not hold for findAllRoots.

Regression suite: Tests/OCCTMathTests/Issue640MathDimensionBoundsTests.swift, 17 tests covering all 20 entry points plus a positive control per function. Proved by injection at four representative guards spanning both failure shapes: removing MathJacobi.eigenvalues’s positivity bound crashes the whole swift test process (exited with unexpected signal code 5, matching raycast’s own #622 precedent) rather than failing one test; removing solveSystem’s startPoint.count == variables check and eigenvalues’s subdiagonal.count == diagonal.count check both instead produce ordinary, reportable test failures with the exact garbage values quoted above; removing findAllRoots’s Sampling.requested call reproduces the same whole-process crash as the positivity case. All four restored and re-verified green. A fifth injection, removing only MathGauss.determinant’s consistency check and leaving positivity, did not reproduce the crash inside swift test’s process (unlike the standalone probe, which crashed reliably at the same input): the read is undefined behaviour, not a guaranteed crash, and its outcome depends on the process’s own memory layout. This is the same point the out-of-bounds-read finding makes at the API level, one level down in the tooling used to demonstrate it.

PR #716 review follow-up: three new closure-return-length traps, a mis-measured integral, and a shared validator (#640)

An automated review of #716 (the PR above) found ten distinct defects, the most severe of which was the PR reintroducing its own bug class in three new places: guarding a caller’s argument arrays against a dimension mismatch, then indexing a closure’s return value against that same dimension with no check at all. A closure returning a short array trapped the process exactly the way #640 exists to prevent, just relocated from the caller’s arguments to the caller’s closure. Fixed at every site that shape occurs: solveSystem/solveSystemNewton’s values/jacobian callbacks, minimize/minimizeNewton’s gradient/hessian, and gaussSetIntegration’s values. minimizeFRPR shares the identical OCCTMathMultiVarGradCallback shape but was not itself named by the review; fixed and tested alongside the others for the same reason. Every guard was proved in a standalone process (the #705 technique, temporarily pointing Sources/OCCTTest/main.swift at a case-selecting probe): removing any one of them reproduces Fatal error: Index out of range, a process trap, not a test failure; restoring it returns nil.

The review’s own finding 2 uncovered a real defect, not a wrong literal. It read a new regression test, gaussIntegrationLengthBounds(), as asserting the double integral of x + y over the unit square is 0.5, and pointed out the true value is 1.0 by symmetry. Both observations were correct, and neither was the actual story: measured directly against the pinned kernel, gaussSetIntegration(nEquations: 1, lower: [0, 0], upper: [1, 1], order: [10, 10]) integrating x + y genuinely returns 0.5 – but not because it computes any correct 2-variable integral. math_GaussSetIntegration’s own header documents “the case M>1 is not implemented”: a probe built directly against the class confirms its constructor only ever varies the first integration variable (Lower.Value(Lower.Lower())), leaving every other component of its working vector pinned at its initial value, 0. So 0.5 is ∫x dx over [0, 1] with y silently held at 0 the entire time, never ∫∫(x + y) dx dy. OCCT’s own runtime check for this (Standard_NotImplemented_Raise_if(NbVar != 1, ...)) does not survive this project’s No_Exception production kernel build (the same class of gap #487/#555/#603 measured elsewhere), so instead of failing loudly it silently computed the wrong thing. Changing the test’s literal from 0.5 to 1.0 would have enshrined a different wrong answer – gaussSetIntegration can never legitimately produce 1.0 for that call, because it never touches the second variable at all. The actual fix is a new guard: gaussSetIntegration now requires lower.count == 1, returning nil for any call with more than one variable, matching what the class actually supports (one variable, any number of equations – a true “set of functions”, each integrated over the same one-dimensional domain). gaussMultipleIntegration is unaffected: its own class, math_GaussMultipleIntegration, integrates recursively over every dimension and was confirmed correct at 2 variables independently. The pre-existing Tests/OCCTIntegrationTests/ OCCTIntegrationTests.swift’s GaussSetIntegrationTests.integrateSet() predated #640 and carried the identical two-variable call and the identical 0.5 assertion; fixed to a valid one-variable, two-equation case ([x, x^2] over [0, 2], giving [2.0, 8/3]) plus an explicit assertion that the old two-variable shape now returns nil.

The remaining seven findings were all confirmed and fixed:

  • Finding 1: both findAllRoots overloads called Sampling.requested(samples) without atLeast: 1, so the default floor of 2 applied even though this file’s own doc comments and the reference docs already documented the valid range as 1...10,000,000. samples: 1 was silently rejected – the closure was never called – before returning []. Fixed by passing atLeast: 1 at both call sites.
  • Finding 7: MathGauss.determinant/MathCrout.determinant returned a bare Double, so the 0.0 sentinel for an invalid dimension was indistinguishable from 0.0, the correct determinant of a genuinely singular matrix. Both now return Double?; nil means invalid input, .some(0.0) means a real, correctly-computed zero.
  • Finding 8: every positivity guard in this family computed n * n or rows * cols with the plain * operator, which traps on overflow for a sufficiently large positive dimension (e.g. n: .max) – the exact process-trap #640 exists to eliminate, reintroduced by the guard meant to prevent it. Fixed by routing every such guard through the new MathDimension type below, which uses multipliedReportingOverflow(by:) (the same idiom Sampling.gridTotal already carries) and rejects rather than traps.
  • Finding 9: the “dimension positive, and every array it sizes matches exactly” check (or, for rows/cols, “the flat array is exactly their product”) was hand-duplicated at 18 call sites with no shared validator, unlike the Sampling family this same PR already reuses for findAllRoots. Sources/OCCTSwift/MathDimension.swift factors it into four functions – valid, consistent, validSquare, validRectangle – and all 18 sites now share it, fixing finding 8’s overflow gap everywhere at once rather than site by site.
  • Finding 10: the 18 changed doc comments carried no fenced swift snippet, which CLAUDE.md makes mandatory for public API changes since context7 only harvests fenced snippets. All 18 gained one, modeled on Sampling.maximumSampleCount’s doc.
  • Also removed: docs/SEMVER.md’s recorded-exception entry for this issue. The maintainer ruled that mechanism is for breaks shipping within a major line; this work ships in v2.0.0, a major, where breaking changes are permitted outright, so no exception entry is needed – this note is the migration record instead. The counters at the top of docs/SEMVER.md are restored to their pre-#640 values (thirteen recorded exceptions), and the pre-existing counter drift #640’s own PR fixed (a stale hardcoded number in #639’s entry, once out of sync with the count at the top of the file) is kept: that entry now reads the count in prose rather than restating it as a number that can drift again.

Full swift test: 5384 tests (5373 baseline + 11 new – 7 in Issue640MathDimensionBoundsTests.swift, 4 in a new MathDimensionTests suite exercising MathDimension directly, the same way Issue558SamplingCountBoundsTests.swift exercises Sampling.requested/capacity/gridTotal directly). swift run Censuses cluster-a: 45 rows. cluster-b: 16 rows. All four gate scripts and their three --self-tests pass from the repo root.

A fillet radius law goes to the edge’s own slot, in the edge’s own contour (#612)

filletEvolving, filleted(edges:startRadius:endRadius:) and filletedVariable all wrote their law with SetRadius(law, NbContours(), 1). Both coordinates were wrong, independently.

The contour. NbContours() is “the contour that exists after the most recent Add”, which is the edge’s own contour only when every Add creates one, a tangent-continuous edge extends an existing contour instead. Measured on a rounded-slot prism (two straight sides joined by two semicircular ends, extruded 20mm), adding a top-rim edge, a bottom-rim edge, then a second top-rim edge:

after NbContours() Contour(edge)
add top-rim edge 1 1
add bottom-rim edge 2 2
add second top-rim edge 2 1

The third edge’s law was written to contour 2, replacing the bottom rim’s own. Asking for top rim 2mm and bottom rim 5mm returned 10271.088459, byte-identical to filleting both rims at 2mm, against the intended 9899.533264.

The slot. SetRadius’s third argument, IinC, is the edge’s index within the contour and selects a distinct per-edge slot. Hardcoding it to 1 sent every edge of a tangent chain to the same slot, so only the last survived. On the same rim, filleting the straight side at 2mm and its tangent arc at 5mm:

  volume
both laws at slot 1 (the old idiom) 9974.608333, the arc’s 5mm overwrote the line’s 2mm
each law in its own slot 10139.793468, both honoured
blendedEdges([(line, 2), (arc, 5)]) 10139.793468, byte-identical

Add(Radius, E), which backs blendedEdges, resolves that slot itself, so it always honoured the very request filletEvolving could not express. There was never a “one law per contour” limit to work around, the conflict was an artefact of the hardcoded 1. #612’s own example (a taper on one edge, a constant on its tangent neighbour) is an ordinary request and now builds, at 10171.225408.

The slot is visible on a single edge too: a 1 → 4 taper on one added edge measures 10273.238348, 10297.711861, 10343.333856, 10402.168644 at slots 1, 2, 3, 4.

The linear entry point was observably wrong after all. Its batch shares one (startRadius, endRadius), so with a constant law two edges of a contour rewrite the same number and nothing moves, but a genuine taper does not: two tangent-continuous edges at 1 → 4 measured 10273.238348, exactly what filleting the first alone produces, against 10297.711861 with each law in its own slot. It now uses OCCT’s own one-call Add(R1, R2, E), which is Add(E) plus the same slot resolution plus SetRadius, verified identical to resolving the slot by hand, and it declines an unfilletable edge by construction. OCCTShapeHistoryFromFilletEdgeVariable moves to the same overload: its SetRadius(radii, 1, 1) was in fact safe, because a single added edge always lands at index 1 of its contour’s spine (measured on all four edges of a tangent rim) and a literal 1 is bounds-checked upstream, but the idiom is gone.

And filletedVariable on such an edge did not merely pick a wrong index, it SIGSEGV’d. When OCCT declines an edge outright (a free-boundary edge of an open shell; 4 of the 12 edges of a box missing one face), Contour(E) is 0 and NbContours() is 0, so the call became SetRadius(law, 0, 1): the unchecked low side of the contour index #505 measured. That is an OS signal, uncatchable in process. Confirmed by re-injecting the old code: swift test exits with signal 11. Such an edge is now resolved to no slot and skipped, which is exactly what Add(Radius, E) already does with it, measured identical to the digit, surface area 465.09733552923257 over all 12 edges of that shell, whichever entry point applies it, so the edge-list fillet family agrees. A batch in which every edge is declined leaves zero contours and fails in Build(), so an all-refused request still returns nil rather than the unfilleted input.

Measure an open shell by area, not volume: it is not a solid, so BRepGProp::VolumeProperties fabricates a number for it, the very defect class #605/#609 exist to reject, and that number is not even a property of the shape. It ranges 746.83 to 748.28 depending on which of the six faces is dropped, while the surface area is 465.097 for all six.

A NaN parameter bound stops being a plausible arc length (#548)

Curve3D.length(from:to:) documents itself as the entry point that tells failure apart from a real measurement, and #408 built the -1.0 sentinel of arcLength(from:to:) / arcLengthBetween(_:_:) on top of that guarantee. It held for the curve types the tests used.

GCPnts_AbscissaPoint::Length(adaptor, u1, u2) does not validate its bounds, and what it does with a non-finite one depends on which of its three internal branches the curve takes. Measured against the pinned kernel (Scripts/repro/548-nonfinite-length-bounds/), on a 5-point interpolated BSpline (domain [0, 485.39], length 528.75) and the analytic types:

bounds segment / line / circle Bezier multi-span BSpline
(f, .nan) nan → nil nan → nil 0, what a zero-width interval measures
(.nan, l) nan → nil nan → nil 528.75, the whole length
(f, .infinity) +inf, passes l >= 0 nan → nil 528.75

The discriminator is not “spline” but “composite”. A 4-pole Bezier propagates NaN like a circle; what separates the BSpline is NbIntervals(GeomAbs_CN) == 4, which sends it down GCPnts_AbscissaPoint::length’s GCPnts_AbsComposite branch. That branch reduces the caller’s range with std::min/std::max, and those return their first argument when the comparison is false: a NaN upper bound collapses the interval onto the start parameter (hence 0), and a NaN lower bound makes both bounds NaN, which turns every per-span skip test false and integrates every span in full (hence the whole length). Not the domain clamping the issue supposed.

Both bounds must now be finite, checked in the bridge by occtValidParameterRange before any adaptor is built, so the contract no longer depends on the integrator’s own NaN handling. .nan and ±.infinity report nil from Curve3D.length(from:to:) and Curve2D.length(from:to:), and -1.0 from Curve3D.arcLength(from:to:) / arcLengthBetween(_:_:), Curve2D.arcLength(from:to:) and Shape.edgeArcLength(from:to:). Finite ranges are untouched, including the reversed, overshooting and wholly-outside ones #506 pinned.

Shape.edgeArcLength gains a failure sentinel. It was the only member of the family with neither an optional nor a sentinel, so a NaN bound on a straight edge returned NaN itself into caller arithmetic. Both spellings (edgeArcLength and edgeArcLength(from:to:)) now report -1.0 on failure instead of 0, matching every other arc-length function in the bridge, 0 is what a genuine zero-width interval measures.

Also measured here, fixed in #600 below: the documented “parameters outside the curve’s domain are clamped to it” holds only on composite curves. A 10-long segment measures 20 over [f, l + span], and a Bezier 122.14 long measures 1002.29, polynomial extrapolation past its poles.

An out-of-domain range measures the curve, not its extrapolation (#600)

Filed out of #548’s measurements. Curve3D.length(from:to:) documented (since #477) that “parameters outside the curve’s domain are clamped to it, so a range wholly outside measures 0 rather than extrapolating the curve’s polynomial”. That was measured on an interpolated BSpline and holds only for curves with more than one GeomAbs_CN interval, because GCPnts_AbscissaPoint::length intersects the requested range with the curve’s own knots in that branch and in no other. Measured over [f, l + span], one domain width past the end (Scripts/repro/600-out-of-domain-length/):

curve own length was now
segment (trimmed line) 10 20 10
Bezier, 4 poles 122.14 1002.29 122.14
arc, half a circle 15.71 31.42 15.71
multi-span BSpline 528.75 528.75 528.75
circle 31.42 62.83 62.83
periodic BSpline 548.51 548.51 1097.02

One rule now, applied in the bridge instead of falling out of which branch a curve’s type takes: a ranged arc length measures the part of the range that lies on the curve. A curve whose parameter domain covers a whole period exists at every parameter, so those measure the whole range and wind, a circle over [0, 4π] still travels two circumferences, which confining would have broken.

The last row is why “confine unless periodic” was not enough. A closed interpolated BSpline is periodic and composite, so GCPnts confined it to its knots and returned one period for a request of two, silently answering half of what was asked, with no failure reported. Winding is computed in occtAdaptorLengthBetween (whole turns × one period’s length, plus the remainder wrapped into the domain) rather than delegated. Verified against a chord-sum reference.

Periodicity alone is not the test. A Geom_TrimmedCurve over half a circle reports IsPeriodic() == true with Period() == 2π, inheriting the basis curve’s periodicity, so the domain must cover a whole period before a range is allowed to wind. Otherwise an arc would measure round the part of the circle its caller trimmed away, which is what it used to do.

All four ranged entry points share the measurement, so a curve, its 2D equivalent and an edge built from it now answer an out-of-domain range identically. Curve2D.arcLength(from:to:) keeps its own range check (a reversed range still fails, that is #549’s decision, not this one’s) but no longer evaluates past the domain: 4771.88 for a BSpline 457.26 long, before.

Found while measuring, filed as #603: GCPnts_AbscissaPoint::Length integrates a single-span conic with one Gauss quadrature over the whole domain and lands 0.34% high on an 8×3 ellipse (36.4894 against a true 36.36686, confirmed by both a Richardson-extrapolated chord sum and a 2M-point Simpson quadrature of the elliptic integral), 1.49% on a 10×1 and 1.74% on a 1×0.05. Sub-ranges are accurate to ~1e-6 and summing two equal halves of the period recovers full accuracy, so it is the single quadrature, the #477 defect, on a curve type with no GeomAbs_CN boundaries to split at. Not fixed here: an accuracy question, not a range-semantics one, fixed in the #603 entry immediately below.

Arc length stops being one quadrature per span (#603)

Curve3D.length on a full ellipse was up to 1.7% wrong, and it was the whole-curve measurement that was wrong, the same ellipse’s sub-ranges were exact.

CPnts_AbscissaPoint::Length integrates |C'(u)| with one fixed-order Gauss rule over the whole range it is handed (order 10 for a conic, 5 for a parabola, 2 × Degree for a Bezier). #477 moved this family onto GCPnts_AbscissaPoint::Length, which splits at the GeomAbs_CN interval boundaries and applies that rule per interval, but a conic has exactly one interval, so the rule still had to cover the entire domain in one go. Measured against a 16-point composite Gauss-Legendre quadrature over 40,000 panels, cross-checked against a Richardson-extrapolated chord sum (Scripts/repro/603-single-span-quadrature/):

curve was now error
ellipse 8 × 3 36.489427 36.366863 +0.337% → 1.7e-14
ellipse 10 × 1 41.243158 40.639742 +1.485% → 1.8e-13
ellipse 1 × 0.05 4.089251 4.019426 +1.737% → 2.9e-14
parabola f=3 over [-100, 100] 1638.523403 1690.708712 −3.087% → 9.6e-14
hyperbola 5/2 over [-4, 4] 285.669841 285.479769 +0.067% → 2.1e-14
Bezier degree 3, whipping poles 48.124451 48.215370 −0.189% → 2.0e-14
interpolated BSpline, 5 points 110.963893 110.970568 −0.0060% → 2.5e-14
circle r=5, line exact exact closed form, no quadrature

A parabola is the worst case in the family, and the only one wrong in the other direction: it gets the lowest order CPnts_AbscissaPoint’s order() hands out to anything curved. The issue named it as worth measuring and had not measured it.

It is not a conic defect, and not a “single span” defect either. The error is set by how much |C'| varies across one integration interval: the 8 × 3 ellipse is 0.337% out over [0, 2π], 0.0001% over [0, π] and exact over [0, π/2]. So a multi-span curve is affected too wherever its spans are wide, a 5-point interpolation is 100× worse than the 40-point curve #477 was tested on. #477 is completed here, not superseded.

Each GeomAbs_CN interval is now measured, then halved, quartered, … until two successive levels agree to 1e-9 relative (occtAdaptorArcLength, Sources/OCCTBridge/src/OCCTBridge_Internal.h). Subdividing the whole range instead does not work and fails silently: on a uniformly-knotted curve the domain midpoint is a knot GCPnts already splits at, so the level-2 sum repeats the level-1 sum bit for bit and a convergence test ratifies an answer that never moved (measured: 110.963893077 at both levels, truth 110.970568312).

The inverse moved with it, and had to. OCCT’s root finder inverts the very quadrature this replaces (CPnts_MyRootFunction::Value is one Gauss rule over [u0, X]), so before this the length and its inverse were wrong by the same amount and parameterAtLength(length) still landed on the curve’s last parameter. Fixing only the length would have moved that to 6.2438 on an 8 × 3 ellipse whose domain ends at 6.2832, and to 6.0358 on a 10 × 1, 0.33% and 1.0% short in arc. So Curve3D.parameterAtLength, Curve2D.parameterAtLength, Shape.edgeParameterAtArcLength, Shape.edgeParameterAtFraction, EdgeCurve.parameter(atAbscissa:) and WireCurve.parameter(atAbscissa:) walk the same subdivided pieces and hand the last, narrow one to the kernel’s solver, which is accurate at that width. A target longer than the curve keeps its old answer (the kernel reports a parameter outside the curve’s own domain, yet reports success; turning that into a failure is a contract change #603 has no measurement to justify).

Every entry point in the family shares the measurement: Curve3D.length, length(from:to:), totalArcLength, arcLength(from:to:), arcLengthBetween(_:_:), Curve2D.length, length(from:to:), arcLength(from:to:), Shape.edgeArcLength (both spellings), Wire.length, EdgeCurve.length and WireCurve.length. #600’s winding is computed from an accurate period, so two turns is exactly twice one.

Cost: roughly 5×, with a floor of three quadratures per interval where there was one, an 8 × 3 ellipse goes 0.11 µs → 3.5 µs, a 200-span BSpline 89 µs → 452 µs. A line, a circle or a 2-pole Bezier/BSpline keeps its closed form (GCPnts_LengthParametrized), converges on the first split with nothing to remove, and stays at 0.02 µs.

Measured rather than assumed, on the two neighbours the issue lists as downstream. GCPnts_UniformAbscissa is not affected, on the worst ellipse its samples are uniform in true arc to 1.9e-10, so sampling by arc length was already right and is untouched. BRepGProp::LinearProperties is affected and is not fixed here: it runs its own integrator and still reports 41.243158 for the 10 × 1 elliptical edge. Shape.linearProperties().length therefore now disagrees with Shape.edgeArcLength on such an edge, where before both were wrong together; reimplementing mass properties is separate work.

The kernel fix ships too (Scripts/patches/0021-*, OCCT.xcframework rebuilt): a new header-only CPnts_AdaptiveIntegration.hxx does the same doubling, used by all four CPnts_AbscissaPoint::Length overloads and by CPnts_MyRootFunction::Value/Values. Both, or neither: the root function’s Value(X) is the same integral, so it currently inverts exactly the bias Length has, which is why GCPnts_UniformAbscissa spaces points uniformly in true arc (2.90e-14 on an 8 × 3 ellipse) while computing a total 0.337% wrong, and why changing Length alone would have broken the sampler. Measured both ways; changed together its spacing is unchanged to the digit. CPnts_AbscissaPoint::Length called directly, where nothing splits at all, goes from 1.0e-1 out on a 200-point interpolation to 2.8e-8. Kernel cost: GCPnts_AbscissaPoint::Length 0.24 µs → 7.2 µs on an ellipse, 87 µs → 444 µs on a 200-span BSpline, GCPnts_UniformAbscissa at 500 points 2.71 ms → 6.20 ms. Filed upstream as Open-Cascade-SAS/OCCT#1420.

The bridge subdivision stays for now, and is redundant once that binary is pinned. ci.yml resolves the pinned released kernel, which has no patch 0021 until a release ships the rebuild, so removing it would fail this issue’s own regression tests there. Layered on the fixed kernel it costs almost exactly 2× (8 × 3 ellipse 3.3 µs → 6.6 µs) and changes no answer, retire it in the release commit that bumps Package.swift’s url:/checksum:.

The 2D arc length that measured 8082 for a curve 353 long (#549)

Curve2D.arcLength(from:to:) and Curve2D.length(from:to:) answered differently on a reversed range: the first reported -1.0, the second measured the span. #506 removed the 3D spelling of that divergence and filed this one as the 2D half, a consistency question rather than a bug report, since both behaviours were documented and each doc page was accurate about itself. Measuring the pair first, as the issue asked, made it a correctness question as well. On a 5-point 2D interpolation (domain [0, 318.433], length 353.508):

range pre-bounded (arcLength) ranged (length)
in domain, forward 169.457 169.457
in domain, reversed raises, reported as -1.0 169.457
overshooting both ends by a domain width 8082.404 353.508
overshooting the upper end only 2549.691 353.508
wholly outside the domain 1.259 0
equal parameters, periodic seam, two full periods, unbounded sub-range agree agree

The reversed-range rejection was the visible half of a pre-bounded Geom2dAdaptor_Curve(curve, u1, u2). The other half was that it evaluated a multi-span curve’s polynomial past its knots and reported the result as an ordinary success: the defect #477 removed from the 3D path, still live in 2D because the two dimensions were fixed one at a time.

OCCTCurve2DLength is gone, with tombstone comments naming the survivor, and Curve2D.arcLength(from:to:) now delegates to length(from:to:), the shape Curve3D.arcLength(from:to:) has had since #408. That was the last pre-bounded arc-length call site in the bridge. New suite Issue549Curve2DArcLengthRangeTests (OCCTGeom2dTests) pins the divergent ranges against a chord-sum reference and checks the 2D answers against the 3D ones on the same points in the z = 0 plane; #409’s suite keeps the -1.0-not-0.0 sentinel it was written for, on an input that still fails. Proved by injection: restoring the pre-bounded call reproduces the figures above through the public Swift API and fails 7 of the 11 tests across the two suites. Probe and full figures at Scripts/repro/549-curve2d-arclength-range-order/.

Two things the measurement corrected on the way past. Curve3D.length(from:to:) documented its clamping unconditionally (“Parameters outside the curve’s domain are clamped to it”), which holds only for a curve with more than one GeomAbs_CN interval: GCPnts_AbscissaPoint::length intersects each interval with the requested range there, but a line, a circle or a 2-pole spline returns |u2 - u1| * ratio and a single-span Bezier integrates the range as given, both unclamped. Measured on four curve types in both dimensions (a Bezier reports 41.256 for a range wholly outside its domain), and the 2D and 3D wording now say so. Separately, Scripts/check-bridge-index.py read the sources whole, so a removed function still counted as existing as long as its tombstone comment named it, which the tombstone idiom (#500, #506) puts there on purpose. It strips comment lines from the sources now, which surfaced three stale entries in the GCPnts_AbscissaPoint index line, all three of them removed arc-length spellings. The new --self-test case is exactly that: a real symbol that survives only in a tombstone.

Noticed, not fixed. Routing 2D onto the ranged form gives it #548’s NaN hole too: on a multi-span 2D curve a NaN bound lands on a domain bound instead of poisoning the integral, so length(from: f, to: .nan) reports 0 and length(from: .nan, to: l) the whole length. On a line, a circle, a segment or a Bezier the NaN propagates and both spellings report failure, which is what the new suite pins. Noted on #548 so one fix covers both dimensions.

Breaking: defeaturing refuses a face the shape does not have, instead of dropping it (#578)

Shape.defeature(faces:) inherited OCCT’s own rule for a face that is not part of the input: BRepAlgoAPI_Defeaturing.hxx says “those that do not belong will be ignored”, and it means it. A request mixing one of this shape’s faces with another shape’s succeeded, removed the one that belonged, and raised no warning of any kind, a success indistinguishable from a real removal, handed back on a shape still carrying the feature the caller asked to remove. The index-addressed spelling of the same operation, withoutFeatures(faces:), has failed the whole call on one bad index since #497; #536 made defeature(faces:) canonical without closing that gap, because a membership rule turned out not to be the line of validation it looks like.

Why it needed measuring first. AddFaceToRemove takes a TopoDS_Shape, and its own documentation calls it “the shape to extract the faces for removal”, the argument need not be a face. Measured on the pinned kernel: a compound holding a face, the input’s own shell and the whole input solid are all accepted and each means the faces it contains, while an edge, a vertex and an empty compound are refused because they contain none. So a rule cannot ask that each element be one of this shape’s faces; it has to explore first, and then decide what a carrier mixing belonging and foreign faces means. Two further measurements make the check implementable and exact: replacing a carrier with the faces it explores to is the same request BREP for BREP, and the input’s own TopExp face map hashes on IsSame, so it accepts the fillet face reversed and rejects the same face measured off an identically-built twin. Probe, full matrix and the rejected alternative at Scripts/repro/578-defeature-face-membership/.

The rule now applied, in occtDefeaturingFacesFromShapes (the #497 skeleton, which now takes the input shape so it can build that map):

Every element of the request must name at least one face, and every face it names must be a face of this shape. Otherwise the whole call returns nil and nothing is removed.

The alternative the issue posed, accept a carrier yielding some belonging faces and quietly keep those, was rejected because it preserves the exact failure mode being removed, one level further down where it is harder to see. Exactly four kinds of request change, and all four were being partly discarded in silence: a foreign face alongside a real one, a compound mixing the two, and an edge or an empty compound alongside a real face. Nothing whose elements all belong behaves differently, carriers included, the whole-solid and shell forms stay accepted, and stay a no-op, because “remove every face” is a question about the algorithm rather than about membership and the kernel’s answer to it is to hand the input back unchanged.

Nothing is filed upstream: the kernel documents what it does and does it. The strictness is this bridge’s contract, and it is now the same contract at both spellings.

New suite Issue578DefeatureFaceMembershipTests (OCCTModelingTests) pins the whole matrix, and Issue536DefeaturingSpellingsTests’ two membership tests, which pinned the old behaviour, are replaced by one that holds the two spellings to the new rule together. Proved against two injections: restoring the pass-through fails six tests across both suites, and injecting the rejected alternative fails exactly one, the mixed-carrier test that exists to pin the design decision.

The index entries that named a real symbol belonging to a different class (#565)

#510 fixed the 135 index entries in OCCTBridge.h that named symbols which never existed. This is the second defect class in the same index: an entry naming a real symbol from a neighbouring class. It passes an existence check and misleads exactly the way a fabrication does, you look up a class, get sent to a function that has nothing to do with it, and conclude the class is wrapped there. #501 hit it directly (GCPnts_UniformAbscissa → OCCTCPntsUniformDeflection*, a symbol that exists and wraps CPnts_UniformDeflection).

17 entries corrected. Every one named a symbol that exists, so the #510 gate called them all clean:

entry named actually drives
BOPAlgo_CellsBuilder OCCTBOPAlgoSplit BOPAlgo_Splitter, already the entry two rows down
ShapeFix_Wire OCCTShapeFixWire* that prefix is ShapeFix_WireVertex + ShapeFix_Wireframe
BRepOffsetAPI_MakePipe OCCTShapePipe* that prefix is BRepFeat_MakePipe
BRepFill_OffsetWire OCCTWireOffset BRepOffsetAPI_MakeOffset
BRepOffset_Analyse OCCTEdgeGetConvexity nothing, convexity is computed by hand
GC_MakeCircle / GC_MakeSegment OCCTWireCreateCircle / OCCTWireCreateLine BRepBuilderAPI_MakeEdge from a gp_Circ/gp_Lin
ShapeAnalysis_WireOrder OCCTWireAnalyze ShapeAnalysis_Wire
LProp_AnalyticCurInf OCCTLPropAnalyticCurInf LProp_CurAndInf; the analytic scan is reimplemented inline
Law_Interpol OCCTLawInterpolate Law_Interpolate, a different class, one letter apart
Geom2d_Direction / Geom2d_VectorWithMagnitude OCCTDirection2D* / OCCTVector2D* gp_Dir2d/gp_Vec2d; neither Geom2d_ class is wrapped at all
ShapeUpgrade_ConvertCurve3dToBezier (+…SurfaceToBezierBasis) , reached via ShapeUpgrade_ShapeConvertToBezier; now says so
BRepCheck_Edge/Face/Shell/Solid OCCTBRepCheckSubShapeValid BRepCheck_Analyzer
BRepOffsetAPI_MakePipeShell OCCTPipeShell* BRepFill_PipeShell
BRepGProp OCCTShapeGetCenterOfMass BRepBndLib, see below

Six classes had no entry at all because a wrong one was standing in for them: ShapeAnalysis_Wire (39 call sites), BRepFill_PipeShell (24), BRepOffsetAPI_MakeOffsetShape, BRepOffset_MakeOffset, Law_Interpolate, LProp_CurAndInf. Geom2d_Direction and Geom2d_VectorWithMagnitude moved to docs/occtswift-wrapping-gaps.md as genuinely unwrapped.

The direction check now gates, per symbol rather than per entry. An entry-level rule (“at least one of these reaches the class”) lets a wrong symbol hide behind its correct neighbours, which is the whole shape of the defect, and GCPnts_UniformAbscissa was only caught in #501 because it happened to be its entry’s sole symbol. Two injected mistakes proved this: adding OCCTShapeFixWireframe to the correct ShapeFix_Face entry went unreported until the rule changed.

Four forms of indirection had to be resolved first, because a check that cannot tell “wrong class” from “reached indirectly” fails on correct entries and gets switched off: wrapper-type fields (XCAFDoc_ShapeTool is OCCTDocument::shapeTool, 66 call sites), file-local static helpers (TDataStd_NamedData, the near-miss that almost got its entry wrongly deleted in #510), static facades (ShapeCustom::SweptToElementary is how ShapeCustom_SweptToElementary is reached), and multi-class headings (RWObj_CafReader/Writer covers RWObj_CafWriter, not RWObj_Writer). Where a class is genuinely reached only through another OCCT class, the entry carries a (via X) aside , and that aside is itself checked, not a silent skip. --self-test grew from 5 cases to 15: five existence shapes, four mis-attribution shapes, and six correct shapes asserted not reported.

A parenthesised aside is commentary, not an attribution. The existence check reads names inside asides by design (that is how #508’s OCCTGCE2dMakeLine* was caught), but the direction check must not: (OCCTWireOffset drives BRepOffsetAPI_MakeOffset, not this) names a symbol precisely to say it does not wrap the class.

A tombstone comment was resurrecting two deleted symbols. real_symbols stripped comments from the header but not from the sources, so // OCCTCurve3DLength lived here: …, left where #506/#549 deleted the function, kept a removed symbol passing the existence check forever. Stripping source comments too flags exactly the two names it should (OCCTCurve3DArcLength*, OCCTCurve3DLength, both still listed under GCPnts_AbscissaPoint) and nothing else.

Filed out of this, not fixed here: Shape.centerOfMass returns the bounding-box centre (#605). OCCTShapeGetCenterOfMass was filed under BRepGProp and does not use it, it takes the midpoint of BRepBndLib’s bounding box, under a comment claiming GProp_GProps::CentreOfMass() “appears to return (0,0,0) for some shapes”. Ground truth on the pinned kernel says otherwise: for a 10-cube at the origin plus a 2-cube 20 units away, CentreOfMass() returns 0.158730159, the analytic answer to nine digits, while both Shape.centerOfMass and properties().centerOfMass return 8.0, the bounding-box midpoint, off by 50x. The workaround was reading a correct zero (a box centred at the origin) as the bug it was working around; every existing test uses a box, where the two coincide.

Comment-only change to OCCTBridge.h (index block) plus Scripts/check-bridge-index.py and docs/occtswift-wrapping-gaps.md; no declaration, no .mm, no kernel patch, no xcframework rebuild.

The approximation consumers did move when #522 landed, and at the continuity that was supposed to be safe (#572)

Patch 0019 (#522) fixed AdvApp2Var_ApproxF2var::mma2ce1_ filling both Jacobi-maxima buffers from the V slot, which made every interior truncation error the surface approximator computed evaluate to exactly zero. #572 asked whether the five kernel classes that construct a GeomConvert_ApproxSurface and never re-check MaxError() moved with it, expecting that they could not have taken a wrong shape because they run at C1 or C2, above the collapse.

They took a wrong shape. Measured on the real wrapper paths, against a stock and a 0019 kernel built as matching -O0 single-TU override links:

request before 0019 after
the sweep’s forced-C1 conversion, tol 1e-4 reported 1.28e-5 with isDone, really 0.876 out reports 2.547 with isDone false, 0.176 out
Surface.toBSpline() on a trimmed offset, tol 1e-4 reported 2.09e-5 with isDone, really 0.104 out reports 0.341 with isDone false, 0.038 out
GeomLib::ExtendSurfByLength on a C0-generatrix revolution, tol 1e-7 reported 9.04e-9 with isDone, really 0.626 out reports 1.887 with isDone false, 0.391 out

Every path that moved moved toward its tolerance, by 1.6x to 5x, and every path that did not move was already meeting it. None of the three reaches its tolerance even now: they cap out at degree 14 and 16 or 24 segments. What changed is that the degree search climbs to that cap instead of stopping at the NDMINU floor with every candidate scoring zero, and that the caller is now told.

Continuity was the wrong axis to predict on. The zeroed error does not only lower the degree floor, it disables the subdivision decision: mma2ce2_’s tolerance test can never fire on a patch interior, so the fit neither raises its degree nor cuts the patch at any continuity. And C0 is reachable at GeomConvert_1.cxx:960, which derives its request from the surface rather than hardcoding one (Geom_OffsetSurface reports IsCNu(N) as its basis surface’s IsCNu(N + 1), so an offset of a B-spline that is C1 but not C2 in U asks for C0) and does not collapse there. The axis that predicts movement is whether the site allows subdivision and whether the input needs any.

Three rows of the issue’s own site table did not survive measurement, which a backtrace probe in GeomConvert_ApproxSurface::Approximate settled rather than a source reading:

  • Shape.sweep cannot reach GeomFill_Sweep.cxx:296. It uses the two-argument BRepOffsetAPI_MakePipe, and ForceApproxC1 is only on the five-argument one. PipeShellBuilder.setForceApproxC1(true) is the sole lever, and it additionally needs the spine’s tangent break to sit inside one edge, since BRepFill_Sweep splits the sweep at spine vertices.
  • BRepOffset_Offset.cxx:1626 is dead. It sits inside if (Polynomial), that argument defaults to false on every Init overload, and the one in-tree caller takes the default. Neither Shape.offset at any join type nor Shape.thickSolid constructs it.
  • GeomLib.cxx:1517 has no OCCTSwift entry point. Its reachable-from list is wider than the issue recorded (fillets through ChFi3d, plus BRepFill_Sweep, BRepOffset_Tool and BRepLib, not just “GeomLib conversions”), but every one of those hands it a surface that is already a B-spline, which is the branch above the construction.

ShapeUpgrade_UnifySameDomain.cxx:3629 needs a base surface that closes in a direction it is not periodic in and is not already a B-spline, which no BRepPrimAPI primitive produces (Uperiod comes from IsUPeriodic()). An extrusion of a closed but clamped B-spline curve reaches it, and its fit is exact on both kernels.

Two regression suites pin the paths that moved, both checked against the released pre-0019 kernel with OCCTSWIFT_REMOTE=1. No production code changes. Reproducer, both transcripts and the probe census: Scripts/repro/572-approx-consumer-sweep/.

faces() dropped a shared face’s second orientation, inverting normals on split solids (#614)

#541/#502 converged every face accessor onto one enumeration, TopExp::MapShapes. That map compares with TopoDS_Shape::IsSame, “same TShape with the same Locations. Orientations may differ” (TopoDS_Shape.hxx:265-271), and NCollection_IndexedMap::addImpl returns the existing index and leaves the stored key untouched on a repeat (NCollection_IndexedMap.hxx:684-710). So a face occurring in one shape both FORWARD and REVERSED collapsed to a single entry carrying whichever orientation was reached first.

That is correct for an index and wrong for a normal, because OCCTFaceGetNormalAtUV reverses the surface normal exactly when the face reads REVERSED. Measured on the pinned kernel, a BRepAlgoAPI_Splitter cutting an origin-centred 10mm box (Shape.box spans −5..5) with the z=4 plane, the plainest two-body result there is:

  before
face occurrences (TopExp_Explorer) 12
distinct faces (TopExp::MapShapes) 11
shared wall stored as FORWARD (lower solid visited first)
its centre / normal (0, 0, 4) / (0, 0, 1)
dot vs lower solid interior (0, 0, −0.5) +4.5, outward
dot vs upper solid interior (0, 0, 4.5) −0.5, inward

The two occurrences are IsSame and not IsEqual. A renderer or CAM pass building outward normals by walking faces() got an inward-facing wall for the second body, and any per-face accumulation silently lost a facet.

That second half was reachable through public API, not just in principle. The face-list CAM helpers filter on normal-derived predicates, so they inherited the collapse, on the same compound:

  before after
horizontalFaces() 3 4
facesByZLevel() at z=4 1 (upward only) 2 (one per owning solid)
upwardFaces() 2 2 (unchanged, opposed normals cannot both face up)

The upper solid’s floor was simply absent.

Fixed by following the split OCCT itself draws, rather than inventing one. The kernel already separates these two jobs and this bridge now matches it:

  • Index → orientation-insensitive. TopExp::MapShapes(S, T, M) is the only type-filtered mapping function OCCT publishes and it accepts only the IsSame hasher’s map, there is no oriented overload (TopExp.hxx:57-60). Upstream’s own canonical stable sub-shape index, the one BREP file persistence writes, is that same map (TopTools_ShapeSet.hxx:192). Shape.faces() stays on it, so no index moves and #541 is untouched.
  • Normal → orientation-sensitive, read off the traversal. BRepGProp::VolumeProperties, where a face’s orientation sets the sign of the volume integral, takes it from ex.Current() (BRepGProp.cxx:322-325); and where it deduplicates, it keeps one IsSame map per orientation (aFwdFMap/aRvsFMap, BRepGProp.cxx:318-338) so a shared wall’s two sides both survive. The oriented indexed map (NCollection_IndexedMap<TopoDS_Shape>, deprecated alias TopTools_IndexedMapOfOrientedShape) is used upstream only for internal algorithm bookkeeping, TopOpeBRepBuild, TopOpeBRepTool, BOPAlgo_Builder, BRepCheck_Wire, ChFi3d, BRepTools_ReShape: never as a public sub-shape enumeration.

New API, all additive:

  • Shape.orientedFaces() -> [Face]: the geometry enumeration: one entry per occurrence, each carrying the orientation it has in its parent. A shared wall appears twice, once per owner, each time with the orientation that makes Face.normal(atU:v:) point out of that owner. An entry’s array position is an occurrence number, not a face index, but each Face still carries the correct Face.index into faces(), so an occurrence stays addressable by every face-index-taking method, and entries sharing an index are the sides of one shared face. On any shape that shares no face this returns exactly faces(), same order, same indices.
  • Face.orientation -> Shape.Orientation: the flag the normal reverses on, so the two sides of a shared wall can be told apart.
  • Bridge: OCCTShapeGetOrientedFaces, OCCTShapeGetFaceOccurrenceCount, OCCTFaceGetOrientation.
  • horizontalFaces(), upwardFaces() and facesByZLevel() now read orientedFaces(): they select on the face normal, so they are geometry consumers. Contract change: all three select over occurrences and so can return two Face values carrying the same Face.index; dedupe on index (or use faces()) if you need one entry per distinct face. On any shape whose faces are not shared all three are unchanged, entry for entry.

    These three are not the complete set of normal-derived consumers of faces(). AAG.buildGraph() (FeatureRecognition.swift:96) also derives normal, isHorizontal, isUpward, isDownward, isVertical and zLevel from faces(), and AAG.detectPockets() selects on them, both public via Shape.buildAAG() and Shape.detectPocketsAAG(). It is not fixed here: faceIndex and adjacencyList are array positions, so moving that graph to the occurrence enumeration changes its identity model. Measured on the z=4 split compound, detectPocketsAAG() returns 2 or 1 depending only on the order the halves were compounded in (upward+horizontal nodes [2, 8] vs [2]), while upwardFaces() correctly returns 2 either way. Tracked as #642.

    One correction to an earlier draft of this entry: upwardFaces() was described as unable to repeat an index, on the reasoning that a shared wall’s two sides have opposed normals. That reasoning holds only when the repeats come from parents bounding opposite sides. Reached twice through parents imposing the same orientation, both entries qualify, Shape.compound([box, box]).upwardFaces() returns indices [5, 5]. Separately, isUpwardFacing tests n.z > cos(tolerance), so at tolerance >= π/2 the threshold is non-positive and admits faces that do not point up at all, including both sides of a vertical shared wall: upwardFaces(tolerance: 1.6) returns 10 entries over 9 distinct indices on a two-solid split. Both are pinned by tests.

Shape.faces()’s own behaviour is unchanged; its documentation now states which of the two contracts it holds. Per-solid enumeration (compound.solids then .faces()) was already correct and is pinned by a regression test so the compound-level fix cannot regress it.

The third “closest point on an edge” entry point, and the edge it was measuring to (#580)

Shape.pointEdgeExtrema(point:edgeIndex:) makes the same promise Curve3D.projectPoint and Edge.project(point:) make, and #539 fixed those two while leaving this one open on a contract question. That question is now measured, and the answer carried two defects rather than one.

It reported the minimum over BRepExtrema_ExtPC’s extrema, which is not the minimum over the edge. Extrema are perpendicular feet, so they exclude the edge’s own two ends, and the one in range can be a maximum:

edge query point was truth
half circle r=5, [0, π] (0, -6, 0) 11 (the far side) 7.81025
half circle r=5, [0, π] (3, -4, 0) (on the circle, off the arc) 10 4.47214
segment [3, 8] along +X (100, 0, 0) nil 92
segment [3, 8] along +X (0, 0, 0) nil 3

Over 189 edge/point combinations against a dense brute-force reference, it was right 101 times, wrong 34 and silent 54. The measured trap: filtering the extrema to the IsMin ones scores 101, exactly what it already scored, because the cases that filter drops are the ones it then leaves with no candidate at all. Adding the ends is what fixes it.

It now routes through #539’s occtNearestPointOnCurveRange, so all three entry points reach one implementation and cannot disagree about the same edge and the same point: 189/189. Repairing in place with BRepExtrema_ExtPC::TrimmedSquareDistances was the smaller diff and tops out at 188, Extrema_ExtPC does not converge on a BSpline queried from (2, 0, 0), leaving the nearer end to answer 2 against a truth of 1.996434, where GeomAPI_ProjectPointOnCurve finds the interior minimum.

solutionCount keeps its meaning, its source and its value; the nil guard is what changed. OCCT models the extrema and the ends as separate things on one object, so “how many extrema were found” was never the wrong number, the ends were simply never consulted. It is no longer a success flag: zero now travels to the caller as the informative state it is (the nearest point is an end) instead of erasing the answer. Note that a non-zero count does not mean the nearest point is one of those feet, the half-circle row above reports solutionCount == 1, and that one extremum is the maximum it used to answer with.

And the second defect, found while fixing the first. edgeIndex walked a bare TopExp_Explorer, which counts one entry per occurrence: a box’s 12 edges are 24 occurrences, since each belongs to two faces. Measured on the pinned kernel, that diverges from the enumeration Shape.edges() and Shape.edge(at:) read (#541’s contract) from index 9 onwards, edgeIndex: 9 measured to the edge through (10, 0, 5) where every other entry point names the one through (5, 0, 10). Not a shared-sub-shape curiosity like #541’s splitter fixture: a plain box.

Behaviour changes for callers. nil now means only “no such edge index, or an edge with no 3D curve”, matching what #539 settled for Edge.project(point:). solutionCount is no longer usable as a success test, it never was, since the guard made solutionCount > 0 unfalsifiable for any non-nil result. edgeIndex 9 and above name different edges on any shape whose edges are shared between faces, which is every solid.

New suite Issue580PointEdgeExtremaTests (OCCTAnalysisTests), 8 tests, plus the pre-existing BRepExtremaExtPCTests.pointToEdge rewritten, its “loop until we find one that gives a valid extremum” was itself a workaround for this defect, and its #expect(result.solutionCount > 0) was unfalsifiable under the guard it was testing. Proved rather than assumed: reinstating the old implementation fails 7 of the 10, and the 3 that pass are exactly the deliberately-unchanged ones (a point with a perpendicular foot, an out-of-range index, and the pre-existing in-range case). Bridge-only, no kernel patch, no OCCT.xcframework rebuild.

Five knot-splitting spellings collapse onto two, and the “strictly weaker” duplicate turned out to be the stronger one (#562)

GeomConvert_BSplineSurfaceKnotSplitting and Geom2dConvert_BSplineCurveKnotSplitting were each wrapped twice, by two families added three releases apart: Surface.knotSplitting and Curve2D.splitIndicesAtDiscontinuities (canonical), and a v0.105.0 set of five (Surface.bsplineKnotSplitsU/bsplineKnotSplitsV/bsplineKnotSplitValues, Curve2D.bsplineKnotSplits/bsplineKnotSplitValues). All five are now deprecated and forward to their canonical sibling; their five bridge functions are deleted.

The premise that the five were strictly weaker did not survive measurement. Curve2D.bsplineKnotSplitValues sized its buffer from the analyzer’s own count, where splitIndicesAtDiscontinuities read a fixed 256 entries and took whatever came back, and the bridge returned the count it had written, so truncation was indistinguishable from a curve with exactly 256 splits. On a cubic with 300 interior knots at multiplicity 3 (302 splits):

call before now
splitIndicesAtDiscontinuities(continuity: .c1) 256 indices, last 256 302 indices, last 302
bsplineKnotSplitValues(continuity: .c1) 302 302

Forwarding without fixing that would have regressed the deprecated spelling, so OCCTCurve2DSplitAtDiscontinuities now reports the true count and the Swift caller re-reads at it, the #481 contract every other member of this family already shared. That is a C-layer contract change: a direct bridge caller that treated the return as “how many were written” must now clamp it. OCCTBridge is not an SPM product, so no Swift package is affected.

What the deleted family carried that the canonical calls did not: the raw knot-table indices. The analyzer reports indices and OCCTSurfaceKnotSplitting converted them to parameters, so the raw form was reachable only through bsplineKnotSplitValues, which constructed the analyzer three more times to get it, once per count call and once for the values. KnotSplitResult now carries uSplitIndices/vSplitIndices alongside the parameters, from the one construction that was already happening, with uSplitParams[i] == bsplineUKnot(index: uSplitIndices[i]) by construction. That answers the issue’s open question about whether the index-returning form was worth keeping: the information was, the three extra entry points were not.

Both deleted values functions also took no buffer capacity at all, each wrote NbSplits() entries into a buffer the caller had sized from a separate call, which was only safe because the analyzer is deterministic. Recorded in the bridge header so it is not reintroduced.

OCCTBridge.h’s cross-reference index named none of the three *KnotSplitting conversion classes, which is half of why the double-wrap went unnoticed for three releases, the index is the map used to find every call site of a class (#510). It gains GeomConvert_BSplineCurveKnotSplitting, GeomConvert_BSplineSurfaceKnotSplitting, and a --- Geom2dConvert --- section that did not exist at all, censused by call site across its six classes.

Tests: Issue562Curve2DKnotSplitDuplicateTests (OCCTGeom2dTests, 4 tests) and Issue562SurfaceKnotSplitDuplicateTests (OCCTSurfaceTests, 5 tests). Expectations are absolute, the fixture’s own knot indices and knot table, rather than agreement between the two spellings, which stopped being evidence the moment one started forwarding to the other. Three injected defects (written-count truncation, 0-based indices, V continuity collapsed onto U) each fail the tests that should catch them; the last is caught by the new suite alone and by none of the existing #403 or #480 coverage.

The healing conversions were returning a straight chord through an offset sphere (#570)

#522 fixed the kernel writing the U Jacobi maxima into the V workspace slot, which zeroed every patch’s interior truncation error, so GeomConvert_ApproxSurface::MaxError() only ever described the boundary iso-curves. That established the number was wrong. Three kernel healing sites make an accept/reject decision on it, which is where a zero stops being a wrong diagnostic and becomes a wrong shape, and nobody had checked them, because every existing test of those entry points uses a box or a cylinder.

Measured against a stock and a patched kernel across ten fixtures, two of the three returned a materially wrong surface:

entry point before after
ShapeCustom::ConvertToBSpline degree 1, 2 poles, deviating by 24 degree 13x10, 14x11 poles, deviating 1.2e-7
ShapeCustom::BSplineRestriction degree 1x7, one pole in U, deviating 23.9999 degree 9x7, 9x8 poles, deviating 5.1e-4

The fixture is a face on an offset sphere over its full domain. 24 is the offset sphere’s own diameter: the fit was a straight chord across the full 2π of longitude, accepted as meeting a Precision::Approximation() tolerance of 1e-6. The restriction result was worse, a single pole in a periodic direction is the whole U direction collapsed to a point, accepted against a 0.01 tolerance it missed by three orders of magnitude, and it was identical at C0, C1 and C2, because the degree-priority loop degrades continuity toward 0 whenever the requested one cannot meet the tolerance within maxDegree. Requesting C2 was not protection.

Six public entry points reached it, all confirmed against the released kernel: Shape.convertedToBSpline(), Shape.withSurfacesAsBSpline(offset:), Shape.convertToBSplineAdvanced(_:offsetMode:) and all three bsplineRestriction* overloads.

The third site is why the other two were reachable at all. ShapeCustom_ConvertToBSpline does not build an approximation itself, it calls ShapeConstruct::ConvertSurfaceToBSpline, forcing GeomAbs_C0 for any offset surface (ShapeCustom_ConvertToBSpline.cxx:148, a 1999 workaround for a hang). So that path did not degrade into the collapsing continuity; it started there. Requesting the offset surface’s own continuity instead returns identical results before and after the patch, the collapse never reaches C2/C3.

No code changed. Patch 0019 already fixes every row above, so what this issue ships is the measurement, the reproducer at Scripts/repro/570-healing-approx-accept/, and seven regression tests pinning the corrected values. Run against the last released kernel, six of the seven fail with exactly the figures above and the seventh, an offset sphere trimmed clear of its poles, which was never affected, passes.

The 1999 workaround stays. Timed on both kernels, the request it suppresses completes in under 5 ms on all seven offset fixtures with no hang, and its results are identical either side of 0019. That is not evidence the hang is gone; it is no evidence the hang ever existed for these inputs. The comment blames a hang, #522 is not a hang, and retiring a hang guard needs a reproduction of the hang. Post-0019 the workaround also costs nothing measurable, forced C0 returns a slightly coarser fit (1.2e-7 against 1.4e-8) that is comfortably inside tolerance either way.

Breaking: plate surfaces honour the tolerance they were given (#571)

Six bridge functions build a surface with GeomPlate_MakeApprox, Shape.plateSurface(through:), plateSurface(constrainedBy:), plateSurface(through:orders:), plateSurface(through:curves:), plateSurface(points:) and Surface.plateThrough(_:). Five of them passed Nbmax = 1 and dmax = tolerance * 10, and between them those two arguments made tolerance unenforceable.

Nbmax caps the number of Bezier patches, and 1 is the one value that disarms the algorithm. AdvApp2Var_ApproxAFunc2Var::ComputePatches derives its cut decision from that cap; at 1 every branch leaves it at “do not cut”, so AdvApp2Var_Patch::CutSense returns the same answer whether or not the G0 criterion was satisfied. The criterion is still evaluated and still reported through CriterionError(): it simply cannot act. Measured on a 25-point wavy plate at tolerance: 0.01: the criterion came back at 0.098 against its own 0.01 threshold, violated, and the surface was returned unchanged. Nbmax = 2 or more fits the same plate to 0.0044.

dmax sets that threshold, as seuil = max(Tol3d, 10 * dmax). tolerance * 10 therefore asked the criterion to accept 100x the tolerance the caller requested, and it is not merely dead weight once subdivision is allowed: at Nbmax = 20 that value reproduces the bad single-patch answer exactly, while tolerance * 0.1 gives the good one. tolerance * 0.1 makes 10 * dmax == Tol3d, so the threshold is the caller’s own tolerance. It is the value the sixth site already used.

All six now share one helper (occtPlateApproxSurface) with one contract. What changes for callers:

  before after
worst deviation, 25-point wavy plate at tolerance: 0.01 0.0724 (7.2x the request) 0.0032
control points in U 9 (a single degree-8 patch) 16
plateSurface(through:) vs plateSurface(points:), same input 22x apart on accuracy identical

Surfaces from these six entry points therefore move, and callers who stored derived geometry should regenerate it. Two related contracts are now explicit rather than implicit:

  • maxSegments: 1 is clamped to 2. Shape.plateSurface(points:maxSegments:) is the one entry point that exposes the cap, and 1 there is not a coarser request but the value that voids tolerance entirely.
  • The approximation’s continuity is passed explicitly, and stays GeomAbs_C1. It is the continuity of the joins between patches, a different axis from the constraint order handed to GeomPlate_PointConstraint/GeomPlate_CurveConstraint, so plateSurface(constrainedBy:continuity:) still applies the caller’s .g0/.g1/.g2 to the boundary constraints only, and does not forward it to the fit. Only C0, C1 and C2 are accepted there at all: G1, G2, C3 and CN each throw AdvApp2Var_ApproxAFunc2Var : UContinuity Error (measured), which is why occtGeomAbsFromSurfaceContinuity: whose order-1 answer is GeomAbs_G1, must not feed it.

This is not fallout from #522, though that is what prompted the audit. GeomPlate_MakeApprox is the one consumer of the defective approximator that does not go through GeomConvert_ApproxSurface, so it took #522’s always-zero interior error without appearing in any census built by grepping for that class. Fingerprinting the control net of 54 plate fits either side of the 0019 patch, a stock override-link against the patched kernel, shows every one identical. Only the reported ApproxError() moved, rising 1.03x to 5.37x as the interior contribution is counted for the first time. At the implicit C1 default the degree floor is already 8, so #522’s collapse could not reach these sites, exactly as #571 predicted.

Two plate suites carrying .disabled("Plate surface operations cause segfault in OCCT") are re-enabled: 18 tests, 13 consecutive clean runs, and they pass against the pre-fix arguments too, so the annotation was stale rather than describing something this change cured. They cover two of the six sites. Reproducers and both transcripts: Scripts/repro/571-plate-approx-contract/.

The cross-reference index stops naming 135 symbols that never existed (#510)

OCCTBridge.h opens with a hand-maintained index mapping each wrapped OCCT class to the bridge symbols that wrap it. It is the map you use to find every call site of a class, and 135 of its symbol references named nothing at all, so a re-audit by symbol name returned zero hits and read as “no call sites, nothing to check”. That is not hypothetical: it is how #484’s fourth, still-unpatched ShapeFix_Face call site was missed. Every one is now rewritten against a measured census of which bridge functions actually reference the class, or removed where no wrap exists:

shape of the staleness example resolution
renamed symbol BRepFeat_Builder → OCCTBRepFeatFuse/Cut OCCTBRepFeatBuilderFuse, OCCTBRepFeatBuilderCut
wrong prefix convention XCAFDoc_ShapeTool → OCCTXCAFShape* the OCCTDocument* assembly surface, 66 call sites, and no OCCTXCAFShape* symbol has ever existed
named one of many GeomLProp_CLProps → OCCTGeomLPropCurve OCCTGeomLPropCLProps plus the ten curvature/tangent/normal functions that share the class
aspirational ShapeFix_Shell → OCCTShapeFixShell entry removed; the gap is recorded in docs/occtswift-wrapping-gaps.md

ShapeFix_Shell was the only entry with no wrap behind it. TDataStd_NamedData → OCCTLabelNamedData* looked identical, no such symbol, no obvious neighbour, but is wrapped, as OCCTDocumentNamedData*, through two lowercase static helpers. A census that only attributes a class to the enclosing OCCT-prefixed function cannot see that, so “no call site found” is a prompt to grep by hand, not a verdict.

The checker that was supposed to prevent this could only see 129 of the 135. check-bridge-index.py split each entry on commas and slashes and required every piece to be a bare symbol. Anything else was skipped without a word: continuation lines of a wrapped entry, headings naming several classes at once (RWObj_CafReader/Writer), and any name carrying an annotation (OCCTShapeFill* (Shape.fill)). It now checks every OCCT-prefixed name anywhere in an entry, which raised what it actually inspects from 454 symbols to 660 and turned up the remaining six, including TDataStd_Integer/Real/AsciiString → OCCTLabel{Set,Get}Integer/Real/AsciiString and TDataStd_IntegerArray/RealArray → OCCTLabel*Array*, naming an OCCTLabel* family that does not exist anywhere in the bridge, and XCAFDoc_ColorTool → OCCTXCAFShape*Color*, a second sighting of the OCCTXCAFShape* prefix that has never named anything. --self-test injects a fabricated name in each of the five shapes an entry can take and asserts it is reported; the parser this replaces catches one of the five and calls the other four clean. The sixth is #508’s GC_MakeLine2d → OCCTGCE2dMakeLine*, whose real wrappers (OCCTCurve2DMakeLineThroughPoints, OCCTCurve2DMakeLineParallel) were already two lines away in the same file.

A second defect class remains, filed as #565: the checker verifies that a named symbol exists, not that it wraps the class the entry files it under. A mis-attributed entry that happens to name a real symbol from a neighbouring class is still invisible, and it misleads exactly the way a fabricated one does.

The cylindrical-hole drill selected parts of the cut result, not parts of its tool (#532)

Kernel patch 0020. Every BRepFeat_MakeCylindricalHole mode that chooses which piece of the drilling tool to keep, PerformThruNext, PerformUntilEnd, the ranged Perform(Radius, PFrom, PTo) and PerformBlind, drove BRepFeat_Builder with SetOperation(Fuse), i.e. BOPAlgo_CUT, and then called PartsOfTool(). That method collects the solids of the builder’s shape, which holds the tool split by the object only after the COMMON pass; after a CUT it is the finished workpiece. So the selection loops compared barycentres of bored plates and registered those plates as “kept parts of the tool”, and PerformResult() then took the kept-parts path with a keep set containing no tool part at all. The caller got the input back with the cylinder’s faces imprinted on it, reported as BRepFeat_NoError throughout.

BRepFeat_Form and BRepFeat_RibSlot, the kernel’s other two users of the same builder, both call the two-argument SetOperation(myFuse, bFlag) with bFlag true. The patch is that call at the four part-selecting sites. Perform(Radius) selects no parts and is untouched, which is why .throughAll was the one extent that already drilled a stack correctly, and why the defect read as “multi-body” rather than “part selection”.

Two corrections to how #532 was originally scoped. PerformBlind is affected too, it was not named because the report came out of #496, which had newly wrapped only the other two extents. And the trigger is not “more than one body”: it is “the cut result has two solids”, which a single solid reaches, an 8mm bar drilled at r = 5 is severed by its own bore, and every part-selecting mode then removed nothing from it.

Measured on a compound of two 50 × 50 × 20 plates on the drill axis, where one bore removes 1570.7963:

call before after
.throughAll 3141.5927 3141.5927
.untilEnd 0.0000 3141.5927
.thruNext 1570.7963 1570.7963
.blind(depth: 20) 0.0000 1178.0972
.range(from: 0, to: 70) 0.0000 3141.5927
.range(from: 0, to: 30) 1570.7963 1570.7963

A single plate is byte-identical before and after, its cut result was one solid, so the branch never ran. A channel and a hollow box, both one solid with two spans on the axis, give the same answer before and after while the selection loop goes from one part to two real tool parts.

One behaviour change beyond the bug. A radius so large the bore swallows the whole solid used to be .invalidPlacement for .untilEnd and .thruNext: under CUT the oversized tool emptied the builder’s shape, so nbparts was 0 and the “the tool meets nothing” guard fired. Under COMMON nbparts is 1 and both extents now return the empty result .throughAll and drilled(at:direction:radius:depth:) always returned. The #496 divergence “through-all status is a false green for the thru-next drill” was that accident, not a contract, and its test now pins the converged answer.

Issue532CylindricalHolePartSelectionTests (Tests/OCCTModelingTests), 7 tests, three of which deliberately cover geometry the fix must not disturb. Run against the last released kernel first: the four that pin the defect fail there and the three non-regression tests pass, which is the proof the suite is measuring the patch. Reproducer and full before/after tables in Scripts/repro/532-cylindrical-hole-part-selection/; a second, unreachable-as-written defect in PerformThruNext’s fallback brace nesting is reported there rather than changed.

One edge-index contract and one radius law for all five fillet entry points (#520)

The five BRepFilletAPI_MakeFillet edge-list functions disagreed on what an edge index means, on what an unresolvable one does, and two validated no radius at all. Settling those three questions turned up two defects the issue did not know were there.

filletedVariable never applied its radius profile. It mapped each relative parameter onto the edge’s own curve parameter range and called SetRadius(radii[i], param, 1). There is no (Real, Real, Integer) overload of SetRadius, so param was truncated to an int and taken as the contour index; the profile was discarded and the caller got a constant radius. Measured on a 20mm box, edge 0:

call before now
filletedVariable(edgeIndex: 0, radiusProfile: [(0, 1.0), (1, 3.0)]) volume 7995.707963, exactly the constant-1.0 result 7981.047467, the profile OCCT was asked for
filletedVariable(edgeIndex: 0, radiusProfile: [(0, 1.0), (0.5, 4.0), (1, 1.0)]) (30mm box) 26993.561945, exactly the constant-1.0 result 26947.284023

Two live SIGSEGV paths. A contour added by the law-taking Add(edge) overload that never receives a radius crashes Build(). It is an OS signal, so the bridge’s catch (...) never saw it and no nil could come back. Both routes were reachable from Swift: filletEvolving with an empty radiusPoints (the old code took neither of its two branches for a count of 0), and filletedVariable on any edge whose curve parameter range does not start at 0, where every truncated contour index exceeded NbElements() and every SetRadius was silently dropped. Box edges all start at 0, which is why the existing tests never saw it; the edges a boolean cut produces do not (6 of the 21 edges of one box cut by another).

Both radius-law entry points now resolve their edges through occtFilletAddEdges and apply their profile through the new occtFilletSetRadiusProfile (OCCTBridge_Internal.h), the same SetRadius(UandR, contour, 1) call OCCTShapeHistoryFromFilletEdgeVariable was already making correctly two functions away. So the same profile now gives the same shape through either entry point, which is pinned by a test.

Three contract changes.

Radius and parameter validation on the two radius-law functions. Neither inspected a single element before. This is not the redundant guard #489 measured for Add(radius, edge): through the profile overload a negative radius is not caught by OCCT at all, reporting IsDone() == 1 and handing back a shape BRepCheck_Analyzer rejects. The parameters are checked against the [0, 1] contract both doc comments already stated, and required to strictly increase, because OCCT renormalises a 3+ point profile with (U - Uf) / (Ul - Uf): equal parameters divide by zero, and descending ones silently reverse the law (7960.426609 against 7963.730821 for the ascending equivalent).

An index that names no edge of the shape rejects the call. Three of the five skipped it and reported success, filleting fewer edges than the caller asked for, a request honoured in part, presented as honoured in full, the same defect class as #439/#442/#443. The other two already rejected, so this is what makes the family agree.

call before now
blendedEdges([(0, 2.0), (99999, 2.0)]) edge 0 filleted, reported as success nil
filleted(edges: [ownEdge, edgeOfAnotherShape], radius: 1.0) ownEdge filleted, reported as success nil
filletedWithFullHistory(radius: 1.0, edges: [0, 99999]) edge 0 filleted, reported as success nil

EvolvingFilletEdge.edgeIndex is 0-based, matching Edge.index and every sibling; it was the one 1-based edge index in the family. Reinterpreting the same numbers would have quietly filleted the neighbouring edge for every existing caller, so the old spelling is @available(*, unavailable) instead: init(edgeIndex:radiusPoints:) fails to build with a message naming the base change, and init(edge:radiusPoints:) takes the Edge itself, the idiom filleted(edges:radius:) already uses. The four call sites in this repo’s own tests failed exactly that way and were migrated.

Ground truth for all of the above is in Scripts/repro/520-fillet-edge-index-contracts/. The new Issue520FilletContractTests suite was run against unmodified code first (#489’s lesson): 10 of its 13 then-runnable cases failed, 1 took the test process down with a SIGSEGV, and 2 passed, one of those for the wrong reason, since a 0-based index was out of range under the 1-based contract. Each guard was then re-checked by injecting the mistake back: the index guard fails 3 tests, the radius guard 2, the parameter guards 2, the empty-law guard SIGSEGVs 3 runs out of 3, and restoring the truncating loop both fails the profile test and SIGSEGVs the re-parameterised-edge test.

Bridge-only: no kernel patch, no xcframework rebuild, nothing filed upstream, passing a double where OCCT’s signature takes an int is our defect, not OCCT’s. Scripts/count-operations.py now skips @available(*, unavailable) declarations, which are retired spellings rather than entry points; the total stays 4295. The skip-an-out-of-range-index idiom survives outside this family, in OCCTShapeHistoryFromChamferEdges, OCCTShapeOffsetPerFace and the 2D fillet/chamfer vertex functions; those are a separate family and are left for their own issue rather than widened into this one.

Breaking: one meaning for a face index (#541)

A face index in this API is now one thing: a 0-based position in the enumeration Shape.faces(), Shape.faceCount and Shape.face(at:) all read, TopExp::MapShapes, one entry per distinct face (TopoDS_Shape::IsSame). It used to be three things, and the three disagreed.

Shape.faces() drove its own bare TopExp_Explorer, one entry per occurrence in the topology tree, and wrote the array position into Face.index. faceCount / face(at:) and most index-taking entry points read the deduplicated map. A handful read that map 1-based.

This is the case #502 (Pass 1b’s sub-shape traversal fix) deliberately left, because face indices are an addressing token the API hands out and takes back, and auditing every consumer is not a one-line change.

The defect was worse than #541 reported. Measured on the pinned kernel (Scripts/repro/541-face-index-contract/), fifteen fixtures walked both ways. The issue’s reproduction is a hand-built Shape.compound([face, face]), but one ordinary modelling operation produces the divergence: a single BRepAlgoAPI_Splitter run cutting a box with a plane leaves two solids sharing the one cut face, 12 occurrences over 11 distinct faces. And because that duplicate is not the last occurrence, every index after it is shifted:

index faces() face(at:)
0–9 the same face the same face
10 one face a different face
11 a face nil

So a caller selecting a face from faces() and handing it to drafted(faces:), shelled(openFaces:) or withoutFeatures(faces:), all map-backed, drafted, opened or deleted a face it had not selected, with no error. #541’s reported symptom (face(at:) returning nil for an index faces() handed out) turns out to be the milder half.

The control matters as much: across the ten fixtures that share no face, primitives, a hollow solid, both booleans, a compsolid, a sewn sheet, two placements of one body, the two enumerations are identical at every index, compared face-by-face rather than by count. Converging them moves nothing on any shape that does not share a sub-shape.

What changed. OCCTShapeGetFaces reads occtMapSubShapes. The fourteen entry points that walked their own explorer to resolve an index (OCCTShapeClassifyPoint2D, OCCTShapeFaceDomainEdgeCount, OCCTShapeBuildLoops, OCCTShapeDraftModification, the three BRepExtrema_Ext*F extrema, the three LocOpe splitters, three ShapeFix/BRepAlgo healers and OCCTBRepCheckSubShapeValid) read occtFaceAt / occtEdgeAt / occtSubShapeAt instead. The 1-based entry points and index outputs moved to 0-based. Two new helpers in OCCTBridge_Internal.h carry the contract in one place.

Six silent behaviour changes, each recorded in SEMVER.md with its migration:

// faces() no longer double-counts a shared face
let pieces = block.split(by: knife)!            // one splitter run
let assembly = Shape.compound(pieces)!
assembly.faces().count      // was 12, now 11, and now equal to assembly.faceCount
assembly.faces().allSatisfy { assembly.face(at: $0.index) != nil }   // was false, now true

// adjacency indices are 0-based, so they index face(at:) directly
for i in box.adjacentFaces(forEdge: edge) {
    box.face(at: i)!        // was box.face(at: i - 1)
}

// the buildWires sentinel moved off 0, which is a real face
box.buildWires(faceIndex: -1)   // every edge of the shape (was 0)
box.buildWires(faceIndex: 0)    // face 0's edges (was rejected)

Also 0-based now: splitByWireOnFace(_:faceIndex:), offsetPerFace’s faceOffsets keys (where an out-of-range key now fails the call instead of being silently skipped, the same silent-success failure #497 fixed for defeaturing), EvolvingFilletEdge.edgeIndex (the one fillet/chamfer entry point in the file that was 1-based), the Poly_Connect mesh family’s faceIndex (their triangle and node indices stay Poly_Triangulation-native 1-based, as do the triangle indices they return), and Selector.PickResult.subShapeIndex, whose “whole shape” sentinel moved from 0 to -1.

Shape.contents was left counting what it counts, and is now documented as doing so. ShapeAnalysis_ShapeContents is a fourth answer and a fifth: NbFaces tracks the explorer, while contentsExtended()’s nbSharedFaces strips the location before deduplicating, so unlike IsSame it also collapses two placements of one face. On a compound of a box with a moved(dx:dy:dz:) copy of itself the three read 12 / 12 / 6. It answers a different question, a complexity metric, and its docs now say so, with the warning that none of its numbers is an index bound.

Regression tests in Tests/OCCTTopologyTests/Issue541FaceIndexContractTests.swift; run against the unfixed bridge, seven of the ten failed and the three controls passed. Full suite green (4880 tests); the only fallout was one existing test whose helper subtracted 1 from adjacentFaces(forEdge:), and the selector tests that asserted the old sentinel.

Not an upstream defect, both OCCT primitives behave exactly as documented, and the kernel is not involved in the base convention at all. No kernel patch, no xcframework rebuild.

Every sampling entry point now bounds the count a caller can supply (#558)

#479 bounded two entry points and recorded that “the same shape is live at fourteen other sampling entry points”. Measuring the family before fixing it found twenty-eight, not fourteen: a caller supplies a count, it sizes a Swift allocation, and it is then cast to the int32_t the bridge takes its count in. [Double](repeating:count:) traps on a negative and Int32(_:) traps past Int32.max, so both ends abort the process rather than returning the documented empty value.

Measured one case per process, since a trap takes the whole harness down with it. Every one of the 28 either trapped or ground on an unservable allocation past 30 s at Int(Int32.max) + 1, and 20 of them trapped on -1. The fourteen the issue did not name are Edge.quasiUniformParameters(count:) (the same method, on the same GCPnts_QuasiUniformAbscissa, as the Curve3D one that was named), Curve3D.samplePoints(first:last:maxPoints:), Surface.drawGrid(uLineCount:vLineCount:pointsPerLine:), Shape’s edgePolyline, allEdgePolylines, edgePoints, contourPoints, uIsoCurvePoints, vIsoCurvePoints and coonsAlgPatch, Wire.orderedEdgePoints(at:maxPoints:), both MedialAxis drawers, and QuadricIntersection.coneSpherePoints.

The ceiling moves out of ArcLengthCurveAdaptor into Sampling.maximumSampleCount, still 10,000,000 and still the measured number #479 justified. EdgeCurve.maximumSampleCount / WireCurve.maximumSampleCount keep working and resolve to it, so nothing #479 shipped breaks.

The bound is not one rule, because the parameters do not mean one thing:

kind parameter decision why
request count, pointCount, sampleCount rejected outside 2...ceiling (empty / nil) the caller asked for exactly this many; returning fewer is the silent-coarsening defect #501 found
capacity maxPoints on an adaptive sampler clamped into 0...ceiling the deflection criterion decides the count and the capacity only truncates, so clamping returns the same points, not coarser ones
grid uCount×vCount, evalU×evalV, (uLineCount + vLineCount)×pointsPerLine the product is bounded, and each factor checked on its own see below

Two things the measurement changed about the fix as the issue specified it:

  • The issue recorded Surface.drawMesh as returning .empty for a negative count. It does, but only when the negative goes to both factors. (-1) * (-1) is 1, a perfectly plausible total, so the allocation succeeds and the bridge rejects the counts on its own. drawMesh(uCount: -1, vCount: 3) is -3 and aborts the process. Bounding only the product would have left that live, so each factor is checked individually as well. Confirmed by injection: with the per-factor check removed, the new suite aborts with Fatal error: Can't construct Array with count < 0 at exactly that case. The same masking applies to Surface.drawGrid and MedialAxis.drawAll.
  • MedialAxis.drawArc’s parameter is named maxPoints, but it is a request. The bridge does numPoints = maxPoints and fills the buffer exactly, so it returns precisely the count asked for (and nothing at all below 2), unlike the genuinely adaptive samplers that share the name. It was written as a capacity first; the verification sweep caught it returning a full 10,000,000 points for a clamped absurd input, which is the coarsening the clamp was supposed to be immune to. It rejects instead.

The multiplications are overflow-checked rather than assumed in range: Int wraps into a trap of its own well before the ceiling is reached, and drawGrid’s two line counts are bounded before being added for the same reason.

Regression suite: Tests/OCCTCurveTests/Issue558SamplingCountBoundsTests.swift, 21 tests. They run in-process only because the fix is what lets them: before it, every assertion in them would have aborted the test harness rather than failing. They compare .count == 0 rather than reading .isEmpty because Swift Testing prints the captured sub-expression on failure, and a regression returning 10 million points would print all 10 million (measured at over 5 GB while injecting a deliberate bug to confirm the suite catches it; #479 hit the same hazard at 880 MB).

The closest point on a curve is now on the curve (#539)

Curve3D.projectPoint(_:precision:), Curve3D.distance(to:precision:), Edge.project(point:) and Edge.distance(to:) all promise the closest point, and none of them delivered it. They had picked a different OCCT call each, and each call is wrong in its own way about a curve that has ends:

  ShapeAnalysis_Curve::Project (was behind Curve3D) GeomAPI_ProjectPointOnCurve, ranged (was behind Edge)
segment trimmed to [3, 8], point (100, 0, 0) parameter 100, distance 0 no answer (nil)
half circle r=5, point (0, -6, 0) distance 1 (the far half) distance 11 (the far side)
point on the full circle, off the arc distance 1.6e-15 distance 10
parabola over [0, 2], point (20, 0, 0) distance 20 (the vertex) distance 20

Truth for those four rows: 92, 7.81, 4.47, 19.60. A distance < tolerance proximity test read the first three as “the point lies on the curve”.

Three distinct defects, not one. ShapeAnalysis_Curve::Project solves on the basis curve for an analytic type, so a parameter outside the domain comes back as though it were on the curve, passing the range does not help, the 7-argument overload documents itself as extending it, and its AdjustToEnds flag changed no measured answer either way. GeomAPI_ProjectPointOnCurve honours the range but returns extrema rather than minima, so the only extremum in range can be a maximum, and it finds nothing at all when the nearest point is an end. And on a parabola or hyperbola both answered with the worst point in range, the defect a parameter clamp, which is what the issue proposed, would not have touched.

Both entry points now share one occtNearestPointOnCurveRange, which takes the minimum over three candidate sources: ShapeAnalysis_Curve’s answer where it landed inside the range, every extremum GeomAPI finds inside the range, and the range’s own ends. Correct on all 51 curve/point combinations swept against a dense brute-force reference (line, circle, ellipse, parabola, hyperbola, Bezier, BSpline and offset curves, trimmed and not), where ShapeAnalysis_Curve alone was right on 37 and GeomAPI alone on 25. Periodic bases need no special handling and get none: Geom_TrimmedCurve normalises its own domain and Project returns the representative nearest it, verified over ten seam-crossing and beyond-one-period cases.

Behaviour changes for callers. Edge.project(point:) and Edge.distance(to:) stop returning nil for a point with no perpendicular foot, every one of a box’s twelve edges used to answer nil for a corner probe outside the box. nil now means what the documentation always said it meant: an edge with no 3D curve. Ordinary in-range projections, unbounded curves and closed curves are unchanged, which is why no pre-existing test moved: every one of them queried a point that has a perpendicular foot.

Curve3D.nearestParameter(to:) (#500) is deliberately untouched and still reports nil for the points above. The two are different questions, the nearest point, which exists for every query point, versus the nearest perpendicular foot, which does not, and Issue500Curve3DNearestParameterTests pins the distinction, updated here to the corrected answer it recorded as-is.

Measured here, fixed in #580. Shape.pointEdgeExtrema(point:edgeIndex:) (BRepExtrema_ExtPC) is a third entry point documented as finding “the closest point on the edge” with the same defect: 11 for the half-circle query above, and IsDone() false for both trimmed-segment queries. It was left out of this change because fixing it meant first deciding what its solutionCount, the extrema count it deliberately exposes, should say when the answer is an end, so that decision was measured here rather than left open, and acted on in #580 above, in the same release.

New suite Issue539NearestPointOnCurveTests (OCCTCurveTests), 12 tests. Proved rather than assumed: reinstating the two original implementations fails 9 of the 12, and the 3 that still pass are exactly the three asserting what was meant to stay the same.

One defeaturing operation, not one per OCCT layer (#536)

Shape.removeFeatures(faces:) and Shape.defeature(faces:) took the same arguments, returned the same type, and neither doc comment mentioned the other. They were the same operation: defeature drove BRepAlgoAPI_Defeaturing, removeFeatures drove BOPAlgo_RemoveFeatures, and BRepAlgoAPI_Defeaturing::Build is a 30-line forwarder that hands its shape, its faces, its history flag and its parallel flag to a BOPAlgo_RemoveFeatures member and returns that member’s result, with Modified, Generated, IsDeleted, HasModified, HasGenerated, HasDeleted and History all one-line delegations to the same member. The bridge had wrapped both layers of one algorithm and given each its own Swift name. This is the sixth spelling of the operation #497 consolidated, left out of that pass because it reached a different OCCT class.

Measured, not read off the source. A deprecation has to answer “is there any input on which they can differ”, not “do they agree on a box”, so both paths were driven exactly the way their bridge wrappers drove them, including the different completion tests, IsDone() against !HasErrors(), over every face of a filleted box in turn, a through hole, a boss, two holes at once, and the requests that fail (no faces, a face from another shape, a mixed request, an input that is not a solid, the same face twice). Identical in every case, compared as full BREP serialisations rather than volumes. The option defaults the forwarding depends on match too (myFillHistory true on both constructors, myRunParallel false from the shared BOPAlgo_Options base), which is what made the two unconfigured objects the same object. Probe and full output at Scripts/repro/536-defeature-removefeatures-unify/.

defeature(faces:) survives, it is the class OCCT documents for application use, it is where the history-carrying sibling already lives, and since #497 it shares one bridge skeleton with the rest of the family. removeFeatures(faces:) is deprecated and forwards to it, with a renamed: fix-it. OCCTBOPAlgoRemoveFeatures is deleted rather than kept for a future caller: OCCTBridge is a target, not a product, so nothing outside this package can link it, and #506 measured what keeping an orphan costs, it freezes whatever contract it had, and this one had drifted already, silently skipping a null entry in the faces array where the surviving path fails the call. That particular drift was not reachable from Swift, since [Shape] has no null elements; it is what an orphaned copy looks like after one side gets a fix, which is the argument against keeping it.

Not fixed, and now written down. A face that is not part of the input is dropped from the request rather than refused: a request mixing one real face with one foreign face succeeds, removes the real one, and emits no warning at all, while a request of nothing but foreign faces fails because nothing is left to remove. That is more forgiving than the index-addressed withoutFeatures(faces:), which since #497 fails the whole call on one bad index. Making the two agree is not a line of validation: AddFaceToRemove takes a TopoDS_Shape and its own documentation calls it “the shape to extract the faces for removal”, and the kernel duly accepts a compound, a shell or the whole input solid as a face carrier, measured. Deciding what a membership rule does with those is its own question, so it was filed separately as #578 and settled there; the behaviour was documented on defeature(faces:) in the meantime, along with the fact that membership is identity, not geometry (a face measured off a separately built but identical shape is foreign).

New suite Issue536DefeaturingSpellingsTests (OCCTModelingTests) compares the two spellings face by face on three fixtures, pins all four spellings of one removal against each other, and pins the membership contract above. Proved against three injections rather than assumed: a forwarder that returns its input unchanged, one that drops the last requested face, and a defeature that reports failure as the input shape each fail the tests that cover them, and only those.

Breaking: one resolution behind the adaptor-backed local properties too, and the raycast normal it was quietly erasing (#529)

#494 converged all 28 GeomLProp_SLProps / GeomLProp_CLProps / GeomLProp_CLProps2d constructions in the bridge onto Precision::Confusion() and recorded the BRepLProp_* half as a separate job on the grounds that it is “a different class family”. It is not. In OCCT 8.0 BRepLProp_SLProps.hxx is nine lines long:

using BRepLProp_SLProps = GeomLProp_SLPropsBase<BRepAdaptor_Surface>;

The same header-only template GeomLProp_SLProps aliases, over an adaptor instead of a Geom_Surface handle. Same Resolution argument, same meaning, so the 18 sites passing a literal 1e-6 were asking whether a quantity exists at a threshold a decade looser than the sites they sit beside. Measured on the pinned kernel, that decade is exactly where they disagreed: on a cone face approaching its apex, Shape.faceLPropMeanCurvature(u: 0, v: 1e-6) returned 0, its spelling of “undefined”, where Face.meanCurvature(atU: 0, v: 1e-6) returned -8.66e5 for the same point of the same face, and the disagreement ran down to v = 3e-7. All 18 now build through occtFaceLocalProps / occtEdgeLocalProps, alongside #494’s occtSurfaceLocalProps and friends.

The census in the issue was three counts off, in the direction of more work rather than less:

the issue says measured
19 literal-1e-6 sites, 16 in OCCTBridge_Properties.mm 18, of which 15 are in Properties.mm
a different class family, so #494’s factories are not reusable the same two templates, one adaptor argument apart
all three Topology.mm sites decide face orientation, not reported values one of them is OCCTFaceGetNormal, which backs Face.normal and every isHorizontal / isUpwardFacing / isVertical predicate
OCCTShapeRaycast’s caller-supplied tolerance is “not obviously wrong” it is the worst site of the 19 (below)

raycast(tolerance:) was erasing its own normals. OCCTShapeRaycast forwarded the caller’s tolerance twice: to IntCurvesFace_ShapeIntersector::Load, where it is the intersection distance it is documented as, and to BRepLProp_SLProps, where it becomes the resolution, which CSLib::Normal uses as a sine tolerance on the angle between the two parametric directions. That quantity is dimensionless and saturates. Measured: raycast(tolerance: 1.0) against a sphere reported no normal for either hit, so both fell back to (0, 0, 1); at 5.0 a box’s downward face came back pointing up. The intersection tolerance now stops at Load. RayHit also gains normalDefined, because the (0, 0, 1) fallback is otherwise indistinguishable from a real upward normal at a genuinely singular hit point. (Additive: RayHit’s memberwise initialiser is internal, so no caller constructs one.)

The curvature-inversion defect #494 found is on this side too, and 1e-6 made its window a decade wider. LProp_CurveUtils::Curvature() returns RealLast() to mean infinite curvature at a cusp, on a path that never assigns the myCurvature field; CentreOfCurvature() tests only |Curvature()| <= resolution, which the sentinel passes, and then divides by that unassigned field. Normal() rejects the sentinel by name and throws; CentreOfCurvature() hands back a point of (nan, inf, nan) as a success. Both edge entry points that invert a curvature now gate on occtCurveCurvatureIsInvertible, the predicate #494 added. Measured on a cubic Bezier whose first two poles sit a controlled distance apart, at u = 0:

pole spacing at 1e-6 (before) at Precision::Confusion() (after)
1e-3 … 1e-6 real centre real centre
3e-7 (nan, inf, nan) reported as a point real centre (0, 1.35e-13, 0)
1e-7 (inf, inf, nan) reported as a point real centre (2.8e-30, 1.5e-14, 0)
1e-8 … 1e-12 (nan, inf, nan) reported as a point nil, no centre of curvature at a cusp
0 (exactly coincident) (0, 0, 0), from the absorbed throw nil

Source-breaking, in four places, all of them entry points that could not previously say “there is nothing here”: Shape.edgeNormalLP(at:) and Shape.edgeCentreOfCurvature(at:) return SIMD3<Double>? rather than SIMD3<Double> (they returned (0, 0, 0), not a direction, not a point, where the quantity does not exist), and Shape.edgeLPropD1(at:) joins them. Not a signature change but a behaviour one: Shape.edgeLPropValue(at:) was already declared optional and never returned nil; now it does.

Two of the three Topology.mm sites were orphans and are deleted rather than converged. OCCTShapeGetHorizontalFaces and OCCTShapeGetUpwardFaces reimplemented Face.isHorizontal / Face.isUpwardFacing over the same midpoint normal, and nothing called them: Shape.horizontalFaces and Shape.upwardFaces filter faces() through the Face predicates. Same reasoning as #506, OCCTBridge is a target, not a product, and an orphan freezes whatever contract it had when it was orphaned, so leaving them would have meant maintaining a second 1e-6 behind a symbol with no callers.

The surviving Topology.mm site’s change is inert, and that is a measurement, not an assumption. CSLib::Normal tests the two first derivatives for nullity against gp::Resolution(), a fixed ~1e-300 epsilon, not the caller’s value, and uses the caller’s value only as the sine tolerance above. A surface whose derivatives merely shrink keeps a defined normal all the way down, which is why tightening the value changes nothing for the normal-only sites. Swept over every face of a box, cylinder, sphere, apex cone, frustum, torus, hemisphere, a fully filleted box and the 662 faces of unify-crash-mmd-kiha10-body5.brep: zero faces changed definedness, zero changed direction, and the horizontal (18) and upward (9) counts on the real fixture are identical before and after. The one geometry that does move is a nearly singular parameterisation, a linear extrusion skewed by 5e-7 radians, whose normal is undefined at 1e-6 and defined at Precision::Confusion().

New suites AdaptorLocalPropsParityTests and AdaptorNormalDecisionTests (OCCTAnalysisTests), 11 tests, following #494’s LocalPropsParityTests pattern: each adaptor-backed entry point is asserted to agree with its Geom_-backed counterpart about definedness exactly, and about value to a relative tolerance, not bit for bit, because a BRepAdaptor_Curve evaluates a Bezier or BSpline through a cache the raw handle does not use, which moves the last ULP (measured: 0.67461923686773151 against 0.6746192368677314 for the same curvature). Proved against three separate injections rather than assumed: restoring the 1e-6 resolution fails 4 tests, removing the two invertibility gates fails 1 (on the (nan, inf, nan) centre), and restoring the raycast tolerance forwarding fails 2. Four tests are controls and pass under all three.

Probes and full figures at Scripts/repro/529-breplprop-resolution/. Bridge-only: no kernel patch, no OCCT.xcframework rebuild.

Noticed, and fixed separately. The face-side curvature getters (faceLPropMaxCurvature and its four siblings) still spelled “undefined” as 0, where the Face counterparts return nil: the silent-zero class #486 and #494 have both hit, and more source-breaking signatures on top of the four here. Filed as #583 rather than folded in; see the next entry.

Breaking: a curvature of zero and no curvature at all stop being the same answer (#583)

Shape.faceLPropMaxCurvature(u:v:) and its four siblings returned the value bare and used 0 (or (0, 0, 0) for faceLPropValue(u:v:)) to mean three different things at once: the curvature is undefined at this point, the handle was null, and this Shape is not a face. #529 had just made them agree with Face.meanCurvature(atU:v:) and friends about whether a quantity exists; they still had no way to say so.

That encoding has no spare value to spend, which is a measurement rather than a style objection. Read through the same BRepLProp_SLProps the bridge builds:

geometry IsCurvatureDefined() what came back
cylinder, any point true Gaussian and maximum curvature both exactly 0
cone, any point true the same two, exactly 0
plane, any point true all four scalars exactly 0; at (0, 0) of a plane through the origin, a point of (0, 0, 0) too
cone apex, sphere pole false 0 / (0, 0, 0)
a Shape that is not a face n/a 0 / (0, 0, 0)

So the sentinel collided with the answer across whole faces of the two commonest solids in the test suite, not at some pathological parameter; and faceLPropIsUmbilic(u:v:) answered false, “the principal curvatures differ here”, at points with no principal curvatures to compare.

Source-breaking, in six places. The five the issue names return an optional: Shape.faceLPropValue(u:v:) → SIMD3<Double>?, and faceLPropMaxCurvature(u:v:), faceLPropMinCurvature(u:v:), faceLPropMeanCurvature(u:v:), faceLPropGaussianCurvature(u:v:) → Double?. The census turned up a sixth in the same block: faceLPropIsUmbilic(u:v:) → Bool?, whose false was the same conflation one type down. faceLPropValue is the one whose contract narrows rather than changes: the point does not depend on the curvature gate, so it is still reported at a cone apex and a sphere pole, and nil there means only “not a face”.

Migration is if let at the call site; the previous behaviour is ?? 0, which is what every caller that ignored the distinction was already getting.

New suite AdaptorCurvatureDefinednessTests (OCCTAnalysisTests), 4 tests, and the three AdaptorLocalPropsParityTests workarounds come out: the parity claim is now (adaptor != nil) == (geom != nil) on every sampled point, the way the edge half of that suite already asserted it, instead of comparing values only where the Geom_ side happened to report one. Proved against two injections rather than assumed: making the curvature gate unreportable (undefined comes back as a successful 0) fails 2 tests, and making the catch unreportable (a non-face Shape comes back as a successful 0) fails 1. Seventeen and eighteen tests respectively are controls and pass under both.

Not changed here, and fixed as #595 (next entry). Six entry points on other types keep the same bare double: Curve3D.curvature(at:), Curve3D.localCurvature(at:), Curve2D.curvature(at:), Shape.edgeCurvatureLP(at:), Surface.gaussianCurvature(atU:v:) and Surface.meanCurvature(atU:v:). The last two disagree with both Face.gaussianCurvature(atU:v:)/meanCurvature(atU:v:) and with their own neighbour Surface.principalCurvatures(atU:v:), which is already optional. A straight edge’s curvature is genuinely 0 and a fully degenerate curve’s is undefined, so the collision is identical; each is a separate public type with its own break surface, so folding them in would have repeated exactly the mistake this issue exists to avoid.

Probe and full figures at Scripts/repro/583-lprop-zero-sentinel/. Bridge-only: no kernel patch, no OCCT.xcframework rebuild.

Breaking: the same zero, on four more public types (#595)

The follow-up #583 filed. Nine entry points now report whether there is a curvature to report, instead of answering 0 when there is not:

Swift was now
Curve3D.curvature(at:) Double Double?
Curve3D.torsion(at:) Double Double?
Curve2D.curvature(at:) Double Double?
Shape.edgeCurvatureLP(at:) Double Double?
Surface.gaussianCurvature(atU:v:) Double Double?
Surface.meanCurvature(atU:v:) Double Double?
Surface.curvatures(u:v:) (gaussian: Double, mean: Double) (gaussian: Double, mean: Double)?
Wire.curvature(at:) Double? unchanged signature, see below
Curve3D.localCurvature(at:) Double deprecated onto curvature(at:)

Migration is if let; the previous behaviour is ?? 0.

The census was six and measured nine. Three decide “undefined” with a hand-rolled magnitude gate rather than an OCCT predicate, so a grep for Is*Defined(), which is how the issue’s list was built, cannot see them:

  • Curve3D.torsion(at:) answered 0 where the first two derivatives are parallel and there is no osculating plane to twist out of. A planar curve’s torsion is genuinely 0, and every circle and ellipse is planar, so the collision runs the other way from the curvature rows and is just as ordinary. It sits four lines below curvature(at:) on the same type; leaving it would have broken Curve3D’s source compatibility twice for one defect.
  • Surface.curvatures(u:v:) returned a bare (0, 0). Its own documented contract is that it agrees with gaussianCurvature(atU:v:) and meanCurvature(atU:v:) “including on whether curvature is defined at all”, which it shares one GeomLProp_SLProps with, and could not say.
  • Wire.curvature(at:) already returned Double?, but only its -1.0 error path reached that optional. The null-derivative branch, a cusp, where the formula divides by zero, returned 0.0, a straight wire’s real answer. No signature change; the branch stops lying. It has no infinity sentinel to offer instead: BRepAdaptor_CompCurve computes the formula directly rather than through GeomLProp_CLProps, so nothing is the honest answer.

The collisions are ordinary geometry, not constructed pathologies. Measured through the same kernel classes the bridge builds:

entry point the real 0 the 0 that meant “no answer”
Curve3D / Curve2D.curvature(at:) any straight curve a Bezier with all poles coincident
Shape.edgeCurvatureLP(at:) any straight edge a sphere’s degenerate pole edge
Surface.gaussianCurvature(atU:v:) every point of every plane, cylinder and cone a cone apex, a sphere pole
Surface.meanCurvature(atU:v:) every point of every plane the same
Curve3D.torsion(at:) every planar curve any straight stretch

The edge row is the one worth reading twice: a sphere carries a degenerate edge at each pole, that edge has no 3D curve at all, and Shape.edge* traversal does not skip it.

A cusp is not an absence and is unchanged. OCCT reports RealLast() there, meaning infinite curvature, and Double.greatestFiniteMagnitude still comes through as a value. It is a real, distinct answer that a Double? has no room for, and it is why the curve half of this family looked better covered than it was.

Curve3D.localCurvature(at:) is deprecated onto curvature(at:), and OCCTCurve3DLocalCurvature is deleted. #494 converged their resolutions, after which the two built the same GeomLProp_CLProps at the same occtLocalPropsResolution() and gated on the same IsTangentDefined(). Per #562’s rule, the axis to check before collapsing is the one nobody listed: here that is the null-handle guard, and it is unreachable from Swift since both wrappers pass a live handle. Measured over the same four curves, including both degenerate rows, the two spellings disagreed on 0 of them.

OCCTWireGetCurvatureAt, OCCTCurve2DGetCurvature, OCCTCurve3DGetCurvature, OCCTCurve3DGetTorsion, OCCTEdgeLPropCurvature, OCCTSurfaceGetGaussianCurvature, OCCTSurfaceGetMeanCurvature and OCCTSurfaceCurvatures return bool with the value as an out-parameter, the shape OCCTFaceGetMeanCurvature and (since #583) OCCTFaceLPropMeanCurvature already use. C-layer contract change; OCCTBridge is not an SPM product (#486).

Deliberately excluded. Edge.dihedralAngle(between:and:at:) has the same hand-rolled shape but returns -1, outside its documented 0...2π range, and its wrapper already maps that to nil, a distinguishable sentinel that already reaches the caller as an absence. The Local*/GeomLProp* families already carry an isDefined out-parameter (#494).

Banked, not changed. Two thresholds stay exactly where they are, because this pass changes how an absence is spelled, not where the boundary between presence and absence falls: OCCTCurve3DGetTorsion compares a squared magnitude against the linear Precision::Confusion() (an effective gate of 3.16e-4 on |d1 x d2|), and OCCTWireGetCurvatureAt keeps its literal 1e-10 on |d1|, the last hand-rolled resolution in the local-properties family after #494 and #529 converged the rest.

New suite Issue595CurvatureDefinednessTests (OCCTAnalysisTests), 9 tests. Proved against two injections: making every definedness gate unreportable fails 8 of the 9 (the cusp test is the control, correctly, since a cusp is not gated); making every catch unreportable fails 1, the not-an-edge case, with 8 controls.

Probe and full figures at Scripts/repro/595-curvature-zero-sentinel/. Bridge-only: no kernel patch, no OCCT.xcframework rebuild.

The PrecisCode census counted a commented-out call site and missed a live one (#573)

OCCTBridge_Surface.mm carries a census of how OCCT itself splits on GeomConvert_ApproxSurface’s PrecisCode argument. It is load-bearing: it is the stated justification for #491 settling both surface approximation entry points on 0. It was built by grepping for the class rather than by reading each hit, so it counted BRepFill_Sweep.cxx:1162, which sits inside a /* */ block spanning :1064 to :1179 and is not compiled, and it missed BRepOffset_Offset.cxx:1626 and the second GeomConvert_1 site (:960) entirely. The live set is 2 sites passing 0 and 6 passing 1:

passes 0, re-checks MaxError() passes 1, never reads MaxError()
ShapeCustom_BSplineRestriction.cxx:852 GeomConvert_1.cxx:786, :960
ShapeConstruct.cxx:265 ShapeUpgrade_UnifySameDomain.cxx:3629
  GeomFill_Sweep.cxx:296
  GeomLib.cxx:1517
  BRepOffset_Offset.cxx:1626

#491’s conclusion is unchanged, but its stated reason was slightly wrong. The split is not “caller’s tolerance versus hardcoded internal tolerance”: BRepOffset_Offset takes the caller’s TolApp and still passes 1, because it gates on IsDone() and never checks the fit against the tolerance it was given. What the two groups actually divide on is whether the site verifies the result, which is the property that puts this bridge in the 0 group. The comment now records that, names both commented-out mentions so the next reader does not re-add them, and says why the two Draw/QA harness sites and GeomPlate_MakeApprox (which drives AdvApp2Var_ApproxAFunc2Var directly and has no PrecisCode, see #571) are outside the list.

Comment-only, no behaviour change. The same correction is applied to Scripts/repro/491-approx-wrapper-drift/ and to #491’s entry below; #522’s own notes had already been corrected. The eight other bridge comments that cite OCCT source (13 line references between them) were checked the same way and all point at live code that says what the comment claims, including the one that correctly describes AdvApprox_ApproxAFunction.cxx:550 as commented out upstream.

Breaking: the last five entry points that quietly dropped an unresolvable index (#568)

A sub-shape index naming nothing now rejects the request everywhere, not just in the fillet family. #520 settled that for the five BRepFilletAPI_MakeFillet edge-list functions and #541 for Shape.offsetPerFace; five sites in the neighbouring families still skipped the entry and built from whatever resolved.

entry point index before now
Shape.drafted(faces:direction:angle:neutralPlane:) face drafts the faces that resolve nil
Shape.shelled(thickness:openFaces:) face opens the faces that resolve nil
Shape.chamferedWithFullHistory(distance:edges:) edge chamfers the edges that resolve nil
Shape.fillet2D(vertexIndices:radii:) vertex rounds the corners that resolve nil
Shape.chamfer2D(edgePairs:distances:) edge (either half of a pair) cuts the pairs that resolve nil

Why this is not tidiness. Measured on the pinned kernel (Scripts/repro/568-index-skip-idiom/), every builder behind these sites reports an ordinary success for a batch it was never told was short. The partial result is IsDone, non-null and BRepCheck_Analyzer-valid; it differs from the complete one only in geometry the caller has no reason to re-measure:

builder, on a 20mm box whole request partial request
BRepFilletAPI_MakeChamfer, 3 edges volume 7885.333333 2 of 3: volume 7922.666667, valid
BRepOffsetAPI_DraftAngle, 4 faces volume 6681.349269 2 of 4: volume 7299.820338, valid
BRepOffsetAPI_MakeThickSolid, 2 open faces volume 2880.000000 1 of 2: volume 3392.000000, valid
BRepFilletAPI_MakeFillet2d, 4 vertices area 1178.539816 2 of 4: area 1189.269908, valid

Shape.drafted was the worst of the five, and only the measurement showed it. Handed no faces at all, BRepOffsetAPI_DraftAngle still reports IsDone() and returns the input shape unchanged (volume 8000 for the same box). So a draft naming only faces the shape does not have (the ordinary result of passing Face values taken from a different shape) succeeded and drafted nothing. The other four at least fail an empty batch (MakeChamfer throws “There are no suitable edges for chamfer or fillet”; MakeFillet2d fails IsDone), and Shape.shelled had its own empty check, which is why for those the mixed batch was the only case that escaped.

The census the issue filed was three ways off, each found by measuring rather than reading:

  • Shape.offsetPerFace was on the list but had already been fixed by #541, which also settled the “is a dictionary of overrides different?” question the issue asked: an override naming no face is an invalid request, not an absent override.
  • Two entries were filed against OCCTWireFilletAll2D / OCCTWireChamferAll2D, which take no indices at all. Their line numbers pointed at OCCTFace2DFillet / OCCTFace2DChamfer, which do, and those are what is fixed here. Same failure mode as #565’s own mis-filing: trust the line, not the name.
  • Two sites the issue did not list, OCCTShapeDraft and OCCTShapeShellWithOpenFaces, spell the skip as an if (idx >= 0 && idx < map.Extent()) { … } wrap rather than a continue, so a census grepping for the continue spelling missed them, including the draft, the most severe of the five.

One resolution helper, not five loops (the issue’s second question). occtUseSubShapesByIndex and occtMappedSubShapeAt (OCCTBridge_Internal.h) resolve a caller’s index array through occtMapSubShapes, the enumeration #502 and #541 already made canonical, and refuse on the first index that names nothing. occtFilletAddEdges is now a five-line wrapper over it, since only the TopoDS_Edge cast was ever fillet-specific, so the fillet family and these five share one statement of the contract rather than agreeing by coincidence. OCCTFace2DChamfer is the one site whose entries name two sub-shapes each, so it reads the map through occtMappedSubShapeAt directly.

Bridge-only: no kernel patch, no OCCT.xcframework rebuild. No operation count change.

Migration. A call that used to succeed by dropping indices now returns nil. Filter your own indices against Shape.faces().count / Shape.edges().count / face.vertices().count if you want the old best-effort behaviour. The difference is that you now choose it.

Three orphaned arc-length bridge functions deleted, and they were not spare copies (#506)

OCCTCurve3DArcLength, OCCTCurve3DArcLengthBetween and OCCTCurve3DLength are gone. #408 routed totalArcLength, arcLength(from:to:) and arcLengthBetween(_:_:) through length / length(from:to:), which call OCCTCurve3DGetLength / OCCTCurve3DGetLengthBetween, leaving all three declared, compiled, and unreachable from Swift. #461 kept them “for C-ABI stability”. That rationale does not survive contact with the packaging: OCCTBridge is a target, not a product, so nothing outside this package can link those symbols through SwiftPM. The layer’s actual stability contract is now written down rather than asserted per PR, in docs/architecture/overview.md under design decision 6.

Keeping them was not free. Retaining an orphan freezes whatever contract it had when it was orphaned, and these three were frozen before two separate fixes. All three returned 0 on failure, the collapse of “the computation failed” into “the curve is genuinely zero length” that #408 fixed. OCCTCurve3DLength also measured through a pre-bounded GeomAdaptor_Curve(curve, u1, u2) instead of passing the range to GCPnts_AbscissaPoint::Length(adaptor, u1, u2), and the two forms disagree wherever the range is not an ordinary in-domain interval. Measured against the pinned kernel on a 5-point interpolated BSpline, 360.99 long:

range pre-bounded (deleted) ranged (live)
in domain, forward 173.76 173.76
in domain, reversed raises, reported as 0 173.76
overshooting both ends by a domain width 8489.78 360.99
wholly outside the domain 1.34 0
equal parameters, periodic seam, unbounded sub-range agree agree

So the “dead copy” still extrapolated a BSpline’s polynomial past its knots, the behaviour #477 removed from every reachable path, and still read a reversed range as zero length. One rewire or one copy-paste and both defects were back, with no test anywhere to catch it. Probe and full figures at Scripts/repro/506-arclength-adaptor-divergence/.

New suite Issue506ArcLengthBridgeContractTests (OCCTCurveTests) pins the surviving behaviour on exactly the four ranges where the forms diverge, with the clamping assertions checked against a chord-sum reference rather than against the implementation’s own answer for the whole domain. Proved against two injections rather than assumed: restoring the pre-#408 wiring fails the cross-spelling test on all three divergent ranges (0 against 173.76, 8489.78 against 360.99, 1.34 against 0), and rewiring length(from:to:) itself onto the pre-bounded form, the accidental-rewire case, fails all four tests.

Noticed, not fixed. length(from:to:)’s NaN handling is curve-type dependent: a NaN bound reports nil on a line, a segment and a circle, but on a BSpline GCPnts_AbscissaPoint::Length returns a plausible number (0 for a NaN upper bound, the whole length for a NaN lower bound), so the failure-versus-zero distinction #408 established holds for the curve types its own tests use and not for BSplines (#548). Separately, Curve2D.arcLength(from:to:) measured through the 2D pre-bounded adaptor deliberately, documented as range-checked, so the 2D and 3D spellings of the same call differed on a reversed range (#549, fixed above in this same release once the 2D form turned out to extrapolate as well). Both are #408/#409 contract questions rather than duplication, so they were filed rather than folded in here.

The PointsToBSpline index entry, and the controls it hid that do nothing (#507)

OCCTBridge.h’s cross-reference index credited GeomAPI_PointsToBSpline to a single function, OCCTCurve3DFit, which does not exist anywhere in the repo. A census of what actually constructs that class found five call sites, not one: OCCTCurve3DFitPoints (the real name behind the index entry), OCCTPointsToBSplineWithParams, OCCTPointsToBSplineWithParameters, the 14-function OCCTBSplineApproxInterp* family, and OCCTWireCreateBSpline, which the issue’s own site list did not have either. All five are now indexed.

Two neighbouring entries were wrong in the same way and are corrected with them. GeomAPI_PointsToBSplineSurface credited OCCTSurfacePlateThrough, which does not use that class at all (it is GeomPlate_BuildPlateSurface plus GeomPlate_MakeApprox, and is now indexed there instead), and omitted OCCTPointsToSurfaceBSpline and four of the five OCCTSurfaceNLPlate* functions. Geom2dAPI_PointsToBSpline had no index entry at all despite backing three functions. The GeomAPI_PointsToBSpline expansion section header covered four functions across three different OCCT classes; it now names all three.

The larger find was behind the mis-attribution. Approx_BSplineApproxInterp was removed in OCCT 8.0.0p1 and its 14-function bridge family was reimplemented on GeomAPI_PointsToBSpline, but the header, the Swift wrapper and docs/reference/GeometrySolvers.md still documented the removed solver’s controls as live. Five of them do nothing: interpolatePoint(_:withKink:), setParametrizationAlpha(_:), setMinPivot(_:), setClosedTolerance(_:) and setKnotInsertionTolerance(_:). performOptimal(maxIterations:) is perform() with the iteration count discarded. nbControlPoints and continuousIfClosed are advisory. setConvergenceTolerance and setProjectionTolerance are one shared tolerance, not two knobs, and the projection one can only tighten it. The reference page also attributed maxError to GeomAPI_PointsToBSpline::MaxError, a method that class does not have: the bridge computes it by projecting each input point back onto the fitted curve with GeomAPI_ProjectPointOnCurve.

Every one of those claims is now a test. The existing four tests asserted only isDone, which is why the drift went unnoticed, so BSplineApproxInterpContractTests pins each contract by comparing densely sampled fits for exact agreement, with a control test proving the comparison does register a real change when the fit tolerance moves. Checked against a deliberate reimplementation of SetAlpha on Approx_ParametrizationType: the no-op test fails and the other six stay green. The deviation that injection produced was 2.2e-14, so exact equality rather than a tolerance is what catches it.

No behaviour changed.

The 12 bridge symbols that never said “OCCT”, and the index entry for a symbol that never existed (#508)

Of ~1161 bridge function declarations, twelve began OCT rather than OCCT, the whole GC_MakeCircle2d/Ellipse2d/Hyperbola2d/Parabola2d family, consistently misspelled across the header, the .mm, and the Swift call site since v0.105.0. Nothing was broken; the mismatch was internally consistent end to end, which is exactly why it survived.

The obvious fix was to insert the missing C. Instead the family is now OCCTCurve2DMake*, matching OCCTCurve2DMakeLineThroughPoints/OCCTCurve2DMakeLineParallel, the GC_MakeLine2d wrappers declared two lines away, the direct sibling of these four classes. The old name was a second inaccuracy layered on the first: GCE2d is a different OCCT package, and v0.156.0 already moved these implementations off it when OCCT 8.0.0 deprecated GCE2d_X into a using alias for GC_X2d. That release deliberately kept the C names for ABI reasons that no longer apply, so a grep for the package these functions were named after led away from the code, not to it.

The header’s cross-reference index carried a matching but distinct error: its one GC 2D entry read GC_MakeLine2d → OCCTGCE2dMakeLine* (bridge symbols retain GCE2d historical name). That prefix has zero occurrences anywhere in the repo, it named neither the conic family nor the line functions it claimed to describe. All five GC_Make*2d families are now indexed under their real symbols.

Scripts/check-bridge-index.py, written for #484/#510 to catch precisely this, could not see the entry: it stripped only a (vX.Y.Z) suffix, so the trailing prose aside left the symbol glued to the annotation and the entry was silently skipped rather than reported stale. It now strips any parenthetical, which also un-hides two further entries that were being skipped for the same reason (both resolve). The stale count is unchanged at 135, the #510 backlog, untouched here.

Documentation for this family named a fourth variant, gce_MakeCirc2d/gce_MakeElips2d/ gce_MakeHypr2d: a real OCCT package, but not the one these twelve call. Corrected to the actual GC_Make*2d classes, along with two rows elsewhere in API_REFERENCE.md left stale by the same v0.156.0 migration (Curve2D.segment is GC_MakeSegment2d; Curve2D.ellipse constructs Geom2d_Ellipse directly and never went through GCE2d_MakeEllipse).

Four tests close the coverage hole the audit found alongside the naming drift: gceCircleParallel, gceEllipse(s1:s2:center:) and gceHyperbola(s1:s2:center:) had no test at all. They assert measured geometry rather than non-nil, and were verified to fail against injected defects, a flipped parallel-offset sign, and swapped S1/S2 apex points. No public Swift API changed; OCCTBridge is not an SPM product, so the C-layer rename reaches no consumer.

The 2D solvers’ circle radius: measured per family, guarded in all of them (#553)

The last unresolved part of the 2D circle-radius family, split out of #514. About 25 sites in OCCTBridge_Geom2d.mm build a gp_Circ2d from caller-supplied doubles as an input to a tangency, bisector, intersection or extrema solver rather than as geometry being returned. #514 stopped there on purpose: a zero-radius circle handed to such a solver is geometrically a point, and several of them have a documented answer for a point argument, so guarding blindly could have removed a query some callers were legitimately making.

So it was measured, per family, against OCCT’s own point overload of the same query. The probe is at Scripts/repro/553-gcc-zero-radius-circle/. No family answers the point question:

family a zero-radius argument gives OCCT’s own point overload gives
GccAna_Circ2dBisec 4 solutions, each duplicated; with both radii 0, two of the three are hyperbolas of major radius 0 GccAna_CircPnt2dBisec gives 2; GccAna_Pnt2dBisec gives the perpendicular bisector line
GccAna_CircPnt2dBisec 2 hyperbolas of major radius 0 a line, so the returned type is wrong, not merely duplicated
GccAna_CircLin2dBisec the point overload’s parabola, twice it once
GccAna_Lin2dTanPar / Lin2dTanPer the point overload’s line, twice it once
GccAna_Lin2d2Tan the point overload’s line, twice it once
GccAna_Circ2d3Tan, three circles 8 solutions: the point overload’s 4, each twice 4, tangency residuals < 1e-14
GccAna_Circ2d3Tan, one circle + two points 0 solutions the circle circumscribing the three positions
Extrema_ExtPElC2d 0 extrema, the distance is lost outright ,
Extrema_ExtElC2d the right distance, twice ,
IntAna2d_AnaIntersection the right point, with ParamOnSecond() NaN, written straight into the caller’s param2 ,

The duplication has a cause worth recording: tangency to a circle of radius 0 satisfies the enclosing and the outside qualifier at once, so the solver enumerates each solution twice. And the hyperbolas of major radius 0 are the same degenerate conic occtValidHyperbolaRadii refuses to construct on the other side of this file, so the bisector families were handing back curves the construction API would not accept.

Every one of those families already has a point entry point in the same API, GccAnaBisector.ofPoints, ofLineAndPoint, Curve2DGcc.lineParallelThrough, linePerpendicularThrough, Shape.circleThrough3Points, the point/point line solvers, and the mixed circle/point overloads. Naming a point as a point already has a spelling, and a degenerate circle is not it. Guard, in every family, a decision reached per family, which converged.

Negative was never the gap. gp_Circ2d’s constructor is constexpr in the header, so its Standard_ConstructionError_Raise_if(theRadius < 0.0, …) does run in a bridge translation unit, the same finding #514 made for gp_Elips2d, and the existing catch already turned it into an empty result. GC_MakeCircle2d reports gce_NegativeRadius, a status rather than a macro, so No_Exception does not void that either. The new guards change what zero does, nothing else.

Two more radius contracts in the same file were converged onto the same predicate while the family was open:

  • The radius of the circle a solver must find. OCCTGccCircle2d2TanRad, OCCTGccCircle2dTanPtRad and OCCTGccCircle2d2PtRad each spelled radius <= 0 inline; four siblings did not check at all. Measured, GccAna_Circ2d2TanRad and GccAna_Circ2dTanOnRad asked for radius 0 return solution circles of radius 0. All seven now share occtValidCircleRadius.
  • Four producer sites #514 did not reach. BRepBuilderAPI_MakeEdge2d reports IsDone() for a zero-radius arc and returns a zero-length edge with both vertices at the centre; GC_MakeCircle2d(ax, 0) succeeds. Curve2D.gceCircleParallel needed the offset checked as well: measured, GC_MakeCircle2d takes the absolute value of radius + dist, so radius 5 offset by -5 gives radius 0 and by -6 gives radius 1, a circle the caller did not ask for, not a refusal.

Every affected Swift entry point now documents its radius contract with a runnable snippet; none of them mentioned it before. A rejected radius returns an empty array (or nil for the producers), which is what these entry points already returned for “no solutions”, so no call site has to move.

22 tests in Tests/OCCTGeom2dTests/Issue553GccZeroRadiusTests.swift, run against a build with the zero rejection removed: 16 fail, and the 6 that do not are the four valid-input controls, the negative-radius test, and the two cases where OCCT’s own wrong answer is itself an empty set (circleTangentCircle2Points and distanceFromPointToCircle), which no assertion can discriminate. Both are noted as such in the test file.

Not changed: extractBisecSolution’s default branch writes (0, 0) for a GccInt_Pnt solution instead of its coordinates. Section 13 of the probe hunts for one, identical, concentric, externally tangent, internally tangent and crossing circles, a point on the circle, a point at the centre, and none of them, nor any zero-radius case, produces one. Left alone rather than fixed speculatively.

The nine 2D conic sites that took a dimension and never checked it (#514)

Split out of #487, which fixed the three gce_Make*2d factories and converged the four conic dimension predicates onto one definition. The rest of the 2D family builds a conic from caller-supplied dimensions at nine more places, and none of them checked. All nine now use the same shared predicates: occtValidCircleRadius, occtValidEllipseRadii, occtValidHyperbolaRadii, occtValidParabolaFocal.

The gap was the zero boundary specifically, not “no precondition at all”. The issue’s premise was that gp_Elips2d’s own Standard_ConstructionError_Raise_if is compiled out. That is true of a call made from inside OCCT, where No_Exception is defined, and it is what #487 measured for gce_MakeElips2d. These nine sites construct the gp_*2d themselves, in a bridge translation unit, where the constructor is constexpr in the header and the check does run: gp_Elips2d(ax, 5, -3), (3, 5) and gp_Parab2d(ax, -2) all raise today and are already caught. What the check never covered is zero, which every downstream algorithm then accepts:

construction measured result on the degenerate input
Convert_EllipseToBSplineCurve, radii (0, 0) 5 poles, degree 2, evaluates to the centre at every parameter
Convert_EllipseToBSplineCurve, radii (5, 0) collapses onto the major axis: (5,0) → (0,0) → (-5,0)
Convert_HyperbolaToBSplineCurve, radii (5, 0) a straight ray
Convert_ParabolaToBSplineCurve, focal 0 3 poles, degree 2, every pole NaN
BRepLib_MakeEdge2d, ellipse (0, 0) IsDone(), zero-length edge, both vertices at the centre
BRepLib_MakeEdge2d, ellipse (5, 0) IsDone(), a segment doubled back along the major axis
IntAna2d_Conic, ellipse (0, 0) all six coefficients 0

Conic2D gains a way to say the conic does not exist. All-zero coefficients cannot carry it: the equation 0 = 0 holds at every point of the plane, so they read as a conic, and they were also what the catch block already wrote. The three bridge functions return bool; in Swift there are three new factories that return Conic2D?:

// new
if let e = Conic2D.ellipse(center: .zero, direction: SIMD2(1, 0),
                           majorRadius: 5, minorRadius: 3) { … }

// old spelling, still compiles, now deprecated
let e = Conic2D.fromEllipse(center: .zero, direction: SIMD2(1, 0),
                            majorRadius: 5, minorRadius: 3)

fromCircle / fromLine / fromEllipse are @available(*, deprecated, renamed:) and forward, returning the all-zero struct where the new spelling returns nil. Not a breaking change: no existing call site has to move, and no signature changed.

Conic2D’s documented equation was wrong. It named a·x² + b·x·y + c·y² + d·x + e·y + f = 0; OCCT’s IntAna2d_Conic::Coefficients returns a·x² + b·y² + 2c·x·y + 2d·x + 2e·y + f = 0, so b is the y² term and c the cross term, and the cross and linear terms carry a factor of 2. A caller who built a conic from the documented order got a different curve. The values themselves never changed; a regression test now pins the order against a radius-5 circle (1, 1, 0, 0, 0, -25).

Scope: the three circle siblings sitting in the same three code blocks (OCCTMakeEdge2dFullCircle, OCCTConvertCircleToBSpline2D, OCCTConic2dFromCircle) are included, along with OCCTConic2dLineCircleIntersect, which shares the same gp_Circ2d construction and would otherwise have been the one entry point in its own block still accepting radius 0. The ~25 remaining 2D circle sites are Geom2dGcc tangency-solver inputs, a different question, filed separately. The 3D equivalents are unsurveyed, as #514 noted.

18 tests in Tests/OCCTGeom2dTests/Issue514Conic2dDegenerateTests.swift. They were run against a build with every guard stripped back out: the 10 rejection tests fail, and the 8 that pin valid input (including “a hyperbola may have minor > major” and the coefficient order) still pass.

The 3D conic sites that took a dimension and never checked it (#554)

The 3D counterparts of #514, which surveyed only the 2D side. Twenty-two sites across OCCTBridge_Curve3D.mm and OCCTBridge_Modeling.mm build a 3D conic from a caller-supplied dimension, or rewrite one on a live curve, and none of them checked it. All twenty-two now use the same four shared predicates: 11 ellipse, 6 hyperbola, 4 parabola, 1 circle. #399’s earlier pass covered the Curve3D factories only.

The census is wider than the issue’s nine gp_Elips sites, because three separate families have the same gap by three different mechanisms, and OCCT’s own checks survive this build to three different degrees:

family sites what OCCT still rejects what gets through
gp_Elips/gp_Hypr/gp_Parab constructed in a bridge TU 11 negatives and inverted ellipse radii: the constructor is constexpr in the header, so its Standard_ConstructionError_Raise_if runs here zero
GC_MakeEllipse/GC_MakeHyperbola 5 negatives and inverted radii, via the maker’s own status (!IsDone()) rather than the macro, which No_Exception deletes inside OCCT zero
Geom_Ellipse/Geom_Hyperbola/Geom_Parabola/Geom_Circle setters 6 negatives, and an ellipse major below its own minor: these are a hand-written if (...) throw, not a macro, so No_Exception never touched them zero

That third row refines #487’s rule rather than restating it: No_Exception voids the macro, not every OCCT precondition. Geom_Ellipse::SetMajorRadius throws from inside OCCT’s own translation unit because the check is spelled by hand.

Zero satisfies every check any of the three writes (minor < 0 || major < minor is false for (0, 0)), which is why it is the one degenerate input that arrived intact by every route.

The sharpest case is GC_MakeArcOfEllipse’s two-point form, where IsDone() is not merely insufficient but actively misleading. That form inverts each endpoint back to a parameter, which divides by the minor radius; at zero both bounds come back NaN and the maker still reports success. The if (!maker.IsDone()) return nullptr line the bridge relied on therefore passed, and Curve3D.arcOfEllipse(…, from:to:) returned a live curve whose parameter range was [nan, nan] and whose every evaluation was NaN. Measured against the pinned kernel, with the identical call on a healthy (5, 3) ellipse as the control:

radii IsDone() resulting parameter range
(5, 3) true [0, 3.14159]
(5, 0) true [nan, nan]
(0, 0) true [nan, nan]

Every site was placed by asking #553’s question, not by which OCCT class it calls. #553 settled whether a degenerate conic can be a meaningful query rather than a broken construction, and answered it by probing whether OCCT actually returns the degenerate answer. Applied here the same question splits this family in two, and the split does not follow the “pure query” line it looks like it should:

  • The three Extrema entry points are guarded, because OCCT does not answer the degenerate question: Extrema_ExtPElC reports NbExt() == 0 against a (0, 0) ellipse rather than the one extremum at its centre, and Extrema_ExtElC reports IsParallel() regardless of what the line does. Same failure shape #553 measured across the Gcc families, and the same conclusion.
  • BndLib and ElCLib are excluded, because they do answer it. ElCLib::Value(1.0, gp_Elips(ax, 5, 0)) is (2.70151, 0, 0), a point on the collapsed segment, which is exactly what that curve is; BndLib::Add returns the true box of it. Both are also void with nowhere to report a rejection, so guarding them would mean widening a signature in order to refuse an input OCCT handles correctly.

The second bullet is the one worth stating explicitly, because “it is only a query” is not the reason, #553 has already shown that a query can be exactly where the wrong answer hides.

Also excluded, and pinned by a test so the exclusion stays deliberate: the four GC_Make* three-point forms. They take no dimension at all, and OCCT’s own status already rejects a degenerate point triple (measured: coincident points, and an S2 lying on the major axis, both report !IsDone()).

No API signature changed, and nothing that used to succeed on valid input now fails. Every affected entry point already had somewhere to say no: nil for the 13 factory and edge builders, false for the 6 setters, and the existing -1 error return for the 3 Extrema functions, which the Swift layer already maps to [].

26 tests in Tests/OCCTCurveTests/Issue554Conic3dDegenerateTests.swift. They were run against a build with every guard stripped back out: 19 fail, and the 7 that pass are exactly the controls, valid input still accepted, the negative and inverted cases OCCT already rejected, the three-point forms, the ellipse major setter that Geom_Ellipse’s own throw already covered, and the BndLib/ElCLib exclusion.

The null-handle guard, swept across the whole geometry-wrapper surface (#478)

#416 added the missing IsNull() guard to OCCTCurve3DTransform and #488 to OCCTSurfaceTransform; #478 asked whether the same shape existed elsewhere rather than fixing a third site alone. It did, in 14 bridge functions, found by walking every function that takes an OCCTCurve3DRef / OCCTCurve2DRef / OCCTSurfaceRef and dereferences the handle it carries:

file functions
OCCTBridge_Curve3D.mm StartPoint, EndPoint, Reverse, Copy, Period, FirstParameter, LastParameter
OCCTBridge_Geom2d.mm Reverse, Copy, Transform
OCCTBridge_Surface.mm Bounds, Copy, UPeriod, VPeriod

Each checked the wrapper pointer and then dereferenced the Handle inside it. Their own siblings, in the same files, check both. OCCTCurve3DStartPoint and OCCTCurve3DEndPoint had no guard at all, not even the wrapper. All 14 now open with if (!x || x->handle.IsNull()). These are uncatchable: the enclosing catch (...) cannot intercept a signal, and the kernel’s own Standard_NullObject preconditions are compiled out of this No_Exception build.

Still latent, and now measured rather than assumed. All 228 sites that bind a handle into an OCCTCurve3D / OCCTCurve2D / OCCTSurface wrapper were classified:

how the handle is obtained sites
a local the same function already IsNull()-checked 97
a maker result behind IsDone() 58
new Geom_* / new GeomEval_* / new Bisector_* (never null) 50
a Copy() / Reversed() down-cast 18
OCCT contract, built from an input already checked 5

The last five are GeomConvert_BSplineCurveToBezierCurve::Arc (twice), GeomConvert_CompCurveToBSplineCurve::BSplineCurve, Geom2dConvert_ApproxArcsSegments::GetResult and Geom_TrimmedCurve::BasisCurve: each is constructed from a handle the caller checked, so none can return null there, but that rests on the class’s contract rather than a check at the site. No bridge call hands back a wrapper carrying a null handle, so nothing here closes a reachable crash. The cost asymmetry is the argument: one condition against a SIGSEGV.

The sweep also found a second, larger class it does not fix: 49 functions guard the wrapper and then pass the unchecked handle to an OCCT API that dereferences it internally, 28 of them without even the wrapper check (Geom2dAdaptor_Curve adaptor(c->curve), new Geom_TrimmedCurve(basis->curve, ...), and so on). Same crash, one hop further out, equally latent. Filed separately rather than folded in here.

Curve2D’s two transform families now share one buildTrsf2D, which is why its dispatcher had drifted in the first place. It was the last of the three geometry types still duplicating the construction, after Curve3D (#416) and Surface (#488). The five immutable functions (OCCTCurve2DTranslate, Rotate, Scale, MirrorAxis, MirrorPoint) reached it through Geom2d_Geometry’s per-operation convenience methods while the in-place OCCTCurve2DTransform built its own gp_Trsf2d, composing the scale case by hand as SetScaleFactor(S) + SetTranslationPart(C * (1 - S)) where the other family used gp_Trsf2d::SetScale(C, S). Verified equivalent before switching, over factors {2.5, 0.25, 1, -1, -3, 0, 1e-9, 1e9} against three centres including (1e6, 1e-6): identical scale factor, identical translation part, identical transformed coordinates, to the bit. They disagree only on the internal gp_TrsfForm tag at S = 1 and S = -1, which is a dispatch hint, not a result.

Two new suites in Tests/OCCTGeom2dTests, because one is not enough. Issue478Curve2DTransform ParityTests holds the two families together across all five kinds, on a segment and on a Bezier. Issue478Curve2DTransformGeometryTests checks each transform against coordinates computed in Swift, with no bridge call in the expectation. Both were run against injected defects: a one-family drift fails the parity suite, while a defect inside the shared buildTrsf2D moves both families identically and leaves the parity suite entirely green, failing only the geometry suite. A parity assertion stops being evidence the moment the thing it compares becomes shared.

The other half of the null-handle sweep, and a checker so it stays swept (#556)

#478 fixed the 14 bridge functions that guard the wrapper pointer and then dereference the Handle it carries. #556 is the class it deliberately left behind: functions that guard the wrapper and then pass the unchecked handle to an OCCT API which dereferences it internally.

60 functions, 73 (function, argument) pairs, all now opening with if (!x || x->handle.IsNull()):

file functions
OCCTBridge_Surface.mm 22
OCCTBridge_Curve3D.mm 17
OCCTBridge_Geom2d.mm 14
OCCTBridge_Modeling.mm 4
OCCTBridge_Topology.mm 2
OCCTBridge_ProjLib_NLPlate.mm 1

The issue counted 49 functions across 5 files; the same walk, run again, finds 61 across 6.

The issue’s headline example is a false positive, twice over. #556 opens with makeQualifiedCurve (Geom2dAdaptor_Curve adaptor(c->curve), “no guard at all”), and argues from Geom2dAdaptor_Curve.cxx:272 that load “has no null precondition at all […] it is an unconditional dereference in every build configuration”. Lowercase load is private. Every caller reaches it through the public Load, header-inline at Geom2dAdaptor_Curve.hxx:108, which opens with a bare if (theCurve.IsNull()) throw Standard_NullObject();, not macro-guarded, so unlike the kernel’s precompiled .cxx preconditions it is not compiled out by No_Exception, which only ever applied to the Release kernel build and never to bridge translation units. And all 12 of makeQualifiedCurve’s call sites, across 7 functions, already reject both the null pointer and the null handle before calling it. It is the one site in the whole sweep left unguarded, now with a comment saying why: its return type (Geom2dGcc_QualifiedCurve, by value) has no null-safe fallback, so the precondition has to live in the callers.

The class is worse than “latent” elsewhere, though. Scripts/repro/556-null-handle-guard-sweep/ drives a null handle into all 35 distinct OCCT entry points these 60 functions call, each in a forked child: 24 crash with an uncatchable signal, 5 raise a catchable Standard_Failure, 6 return normally. Geom2dAdaptor_Curve is in the mild group. new Geom_TrimmedCurve, new Geom_OffsetCurve, new Geom_RectangularTrimmedSurface, ShapeAnalysis_Curve::IsPeriodic, BRepLib_MakeEdge2d, GeomConvert::CurveToBSplineCurve, BRepAlgoAPI_Section and ShapeConstruct_Curve::ConvertToBSpline are not. ShapeAnalysis_Surface is the one to remember: constructing it from a null handle returns normally and the crash lands at the first ValueOfUV, so a probe that only constructed it would have cleared that entire 11-function family.

Three findings the issue’s own reproduction recipe could not reach, because it walks scalar parameters only:

  • OCCTCurve2DPointAt is a dereference site (curve->curve->D0(t, pt), with no guard at all) that #478’s sweep missed outright. Fixed here rather than left for a third pass.
  • Five functions take an array of wrappers. OCCTGeomFillGordon and OCCTGeomFillGordonReport checked !profiles[i] and passed the handle unchecked; OCCTConcatenateCurves3D, OCCTConcatenateCurves2D and OCCTCurve3DJoinCurves checked curves[i] inside the loop but never curves[0], the element every one of them handles separately to seed the accumulator.
  • OCCTBridge_ProjLib_NLPlate.mm was not in the issue’s file list at all.

The deliverable is the invariant, so it is now checked rather than remembered. Scripts/check-null-handle-guards.py is the same walk that produced both #478’s list and this one, committed alongside check-bridge-index.py and gating on exit status. Verified against two injected regressions: reverting one guard, and adding a new function that checks only the pointer. It reports both, and reports nothing on the fixed tree. DownCast(x->handle) is excluded as a use, since down-casting a null handle returns null and every such site checks the cast result. Without that exclusion the walk reports several hundred false positives.

Still latent: #478’s classification of all 228 wrapper-producing sites holds, so no bridge call can hand back a wrapper carrying a null handle today. What changed is the measured cost of weakening that invariant later.

Fix: arc-length sampling aborted the process on a sample count it could not allocate (#479)

EdgeCurve/WireCurve points(spacing:) derived its sample count from the caller’s spacing with a lower clamp only, max(2, Int((length / spacing).rounded()) + 1), and points(count:) then allocated count * 3 doubles from it. Neither end of that had an upper bound, and both failure modes are a process abort rather than an empty array or a clamped result. Measured on a 200-unit wire, one case per process:

call before
points(spacing: 1e-9) count 2e11 + 1, i.e. a ~4.8 TB allocation
points(spacing: 1e-18) Fatal error: Double value cannot be converted to Int because the result would be greater than Int.max
points(spacing: 5e-324) same trap
points(count: Int(Int32.max) + 1) trap: Int32(count) overflows the bridge’s own count type
points(count: Int.max) trap: count * 3 overflows

The last two need no spacing at all, so the bound belongs on points(count:), where the allocation is, and points(spacing:) has to derive its count without ever converting an out-of-range Double. Both are now bounded by ArcLengthCurveAdaptor.maximumSampleCount, 10 million points, declared once and shared by both types; the derivation stays in Double until it is known to be in range. Anything past the ceiling returns an empty array, matching what spacing <= 0, a NaN spacing, a zero-length curve and count < 2 already did. There is deliberately no clamping: a request the ceiling cannot honour fails visibly rather than coming back silently coarser than what was asked for, which is the defect #501 found in the one sampler that did clamp.

The ceiling is a bound on the allocation, not on what is useful (one sample costs 24 bytes in the packed bridge buffer plus 32 in the returned array), and it is honoured exactly, not aspirational: at the ceiling, 10,000,000 points come back in 46 s at 624 MB resident, and 10,000,001 returns empty. It is also two orders of magnitude below the int32_t the bridge takes its count in.

The hazard was pre-existing and duplicated: EdgeCurve and WireCurve each had their own copy of the body until #422 moved it verbatim into the shared extension. Both points(count:) implementations now delegate their allocation, count contract and unpacking to one sampledPoints(count:_:) skeleton, which also brings them onto unpackSIMD3 (#419), the two sites the shared unpack helper had never reached.

The same shape is live at fourteen other sampling entry points across Curve3D, Curve2D, Edge, Surface, Shape and BRepGraph: every one of them traps at Int(Int32.max) + 1, and the eight Curve2D/Curve3D ones trap on a negative count too, inside [Double](repeating:count:) itself, despite documenting “must be at least 2, else empty”. Measured one case per process, not assumed. Filed as #558 rather than widened into this fix: maxPoints on an adaptive algorithm is a capacity rather than a request, and uSamples/vSamples bound a product, so those need a contract decision per parameter rather than this one’s ceiling applied uniformly.

Corrected by #558: the family is twenty-eight entry points, not fourteen, this census missed half of it, including Edge.quasiUniformParameters(count:), the same method on the same OCCT class as the Curve3D one it did name. The drawMesh row of its table is also wrong: a negative count only survives when it is passed to both factors, where the two negatives multiply to a plausible positive total. See the #558 entry above.

The knot-splitting continuity cap, on the four fifths of the family #398 did not reach (#480)

#398 established that a knot-splitting continuity documented as 0=C0, 1=C1, 2=C2 lists every value that does nothing on ordinary cubic geometry: the analyzers split a knot only when degree - multiplicity < ContinuityRange, and a cubic with simple interior knots is already C2 there. It widened Curve3D.continuityBreaks to ParametricContinuity, whose .c3 is reachable. The rest of the family kept the cap, and a census by OCCT class rather than by the issue’s site list found more of it than the issue named: four analyzers, nine public Swift entry points, eight still typed Int and still documenting the range that does nothing.

All of them now take ParametricContinuity:

API was now
Surface.knotSplitting(uContinuity:vContinuity:) Int = 1, Int = 1 .c1, .c1
LawFunction.knotSplitting(continuityOrder:) Int = 2 .c1
LawFunction.knotSplitParameters(continuityOrder:) Int = 2 .c1
Curve2D.splitIndicesAtDiscontinuities(continuity:) Int = 1 .c1
Surface.bsplineKnotSplitsU/V(continuity:), Surface.bsplineKnotSplitValues(continuity:) Int ParametricContinuity
Curve2D.bsplineKnotSplits(continuity:), Curve2D.bsplineKnotSplitValues(continuity:) Int ParametricContinuity

Source-breaking: callers passing integer literals need the spelled case (0 → .c0). That is the point: the raw Int is what let a documented range consisting entirely of no-ops go unnoticed for five releases.

The defaults were revisited and deliberately left at .c1, now uniform across the family (the two law methods moved from 2). Measured on a cubic with four interior knots: at .c1 a multiplicity-3 knot (a genuine kink) is reported and nothing else is; at .c3 every interior knot is reported, which is a Bezier decomposition rather than a discontinuity report. So .c1 answers “where does this actually kink”, which is the question a default should answer, and the fix for the issue is that .c3 is now spellable, not that it is now the default.

Also measured, and now documented rather than left implicit: all four analyzers (GeomConvert_BSplineCurveKnotSplitting, Geom2dConvert_BSplineCurveKnotSplitting, GeomConvert_BSplineSurfaceKnotSplitting, Law_BSplineKnotSplitting) run a byte-identical algorithm and agree on every count; the useful domain is 0...degree and saturates there, so on a degree-5 BSpline nothing below .c5 reaches a simple interior knot and .c3 is the strictest question this vocabulary can ask (toBezierSegments()/toBezierPatches() is the dedicated API for the every-knot split at the far end of that ladder); and a negative range throws Standard_RangeError through an explicit throw rather than a *_Raise_if macro, so unlike most OCCT preconditions it survives this kernel’s No_Exception build and reaches the bridge’s catch(...).

Two doc corrections found on the way: Surface.knotSplitting was attributed to BSplSLib::KnotSplitting, which is not what it calls, and the bridge header still documented the 0=C0, 1=C1, 2=C2 range on OCCTCurve3DBSplineKnotSplits, the one function #398 had already fixed on the Swift side.

New tests pin the measured contract in all three affected domains rather than the signature, so they also catch the opposite mistake of decoding the enum to a GeomAbs_Shape first: GeomAbs_C2 is ordinal 4, which would split at every knot where .c2 must split at none. Both mistakes were injected and confirmed to fail the new cases.

One pipe shell, and the sweep mode it was quietly discarding (#503)

Four bridge functions each built their own single-profile BRepOffsetAPI_MakePipeShell, and each was OCCTShapeCreatePipeShellMultiSection with profileCount = 1 and some arguments nailed shut. Confirmed rather than assumed: OCCT’s Add(profile) is Add(profile, false, false) by default argument, and the two spellings produce byte-identical BREP. All four are gone. Every Add()-based pipe sweep is now one function, and the workaround comment SetIsBuildHistory(false) // avoid SEGV on closed spine+profile went from six pasted copies to one.

Two of the four accepted an OCCTPipeMode they could not express. Their mode switch had a case for FixedBinormal and Auxiliary that fell through to SetMode(Standard_False), plain Frenet, and returned the resulting solid as a success. At the C level that branch was unreachable from Swift, but the same defect had already surfaced in the public API: Shape.pipeShellWithTransition(mode: .fixed(binormal:)) swept Frenet. Measured on an S-curve spine with a 5×3 rectangular section: 180.287 requested as a fixed binormal, where the fixed binormal builds 149.999. A straight spine will not show this: with no torsion the modes coincide exactly, which is how it survived a suite that only ever swept straight lines and gentle arcs.

A mode whose own argument is unusable now fails the call instead of substituting a different mode: .fixed(binormal: .zero) and an auxiliary spine OCCT rejects both return nil.

Three things became reachable that were not, all of which change the output rather than being inert knobs:

control was measured effect
transition: on a multi-section sweep single-profile only 113.05 / 256.65 / 240.53 for transformed / rightCorner / roundCorner
withContact: / withCorrection: on a single-profile sweep multi-section only correction re-orthogonalises a tilted section: 205.208 → 251.327
.auxiliary(spine:) with one profile untested, no coverage anywhere builds, and differs from Frenet

API changes. Shape.pipeShell gains transition:, withContact: and withCorrection:; Shape.pipeShellMultiSection gains transition:. All default to the previous behaviour, so no call site changes. Shape.pipeShellWithTransition is deprecated (it is now pipeShell with one argument set) and forwards, honouring every mode. Shape.pipeShellWithLaw keeps its own entry point, since SetLaw is not an Add() sweep and OCCT’s header warns against combining the two; it shares the build tail.

Verified by capturing every pipe-shell call path’s volume, area and face count before the change and re-running after: every figure is identical except pipeShellWithTransition(mode: .fixed(...)), which moved to the value its non-transition sibling already produced. The new tests were also run against a deliberately reintroduced fall-through: 4 of 8 fail, naming the substituted mode.

Breaking: the junction analysers now say what they measured, and stop reporting what they did not (#495)

Source-breaking, in one place: Curve3D.ContinuityAnalysis and Surface.ContinuityAnalysis expose isC0/isG1/isC1/isG2/isC2 as Bool? rather than Bool. nil means “the order you asked for never measured this class”. SEMVER.md records the exception; a shim is impossible, because Swift does not overload a property on its type.

LocalAnalysis_CurveContinuity and LocalAnalysis_SurfaceContinuity run exactly one branch of a switch on the order they are constructed with, and only that branch’s quantities are ever computed. Every other predicate then compared a member still at its 0.0 initialiser against a tolerance and answered true whatever the geometry did. A sharp 90° corner analysed at order C0 reported isC2 == true, with c2Angle == 0.0, a perfect second-derivative match, to go with it. The five branches are cumulative only along their own ladder, and no order measures all five, not even the .c2 default, which never looks at G1 or G2:

order measures
.c0 C0
.g1 C0, G1
.c1 C0, C1
.g2 C0, G1, G2
.c2 C0, C1, C2
// Before, compiles, and lies. The .c2 default never computes G1.
if analysis.isG1 { … }

// After, ask for what you want measured, and nil says when you did not.
let a = c1.continuityWith(c2, u1: e1, u2: s2, order: .g1)!
a.holds(.g1)   // Optional(true)
a.holds(.c1)   // nil, .g1 does not measure C1
a.measured     // [.c0, .g1]

The angle and ratio outputs are gated the same way: an unmeasured class now reports -1 (the “not applicable” value those fields already used) instead of 0.0. flags is masked to the measured set, and measured/holds(_:) are the new way to read it.

Three more things fell out of the same audit, none of them source-breaking:

  • ContinuityAnalysis.status was never a measurement. ContinuityStatus() returns the order the analyser was constructed with, verbatim. It is now order: ContinuityClass, documented as the request after saturation, which is the one thing it can honestly report; status remains as a deprecated Int shim. Every test that touched it asserted status >= 0, which is why the echo went unnoticed.
  • order: is typed. Curve3D.continuityWith and Surface.continuityWith took a raw Int = 4, the last two continuity parameters #398/PR#436 did not reach, a caller could pass 5, -1 or a value borrowed from an unrelated continuity enum and be clamped without being told. Both now take a ContinuityClass = .c2, with a deprecated Int overload that decodes identically.
  • Shape.continuityOfFaces documented its own return values wrong. Its comment said 5=CN; CN is ordinal 6, and 5 (C3) is a value BRepLib::ContinuityOfFaces cannot return at all. The function was always right, it casts the enum straight through, so there was no lookup table for the comment to be describing, but a caller matching 5 for “smooth” never matched anything, and one receiving 6 had no documented meaning for it. Same wrong string had been copied into the bridge header and the Swift doc comment. continuityClassOfFaces(edge:face1:face2:tolerance:) -> ContinuityClass? is the typed replacement; the Int spelling is deprecated, not changed.

Measured, not inferred: a box edge reports .c0, a filleted box’s blend joins report .g1, a cylinder seam reports .cN (ordinal 6), and .c3 appears nowhere. Also pinned by test, since the default order walks straight into it: the .c2 branch needs a non-zero second derivative in both parametric directions, so Surface.continuityWith at the default returns nil for a plane (none in either direction) and for a cylinder (none along its axis), ask for .c1 or .g1 on planar or ruled geometry. Six pre-existing tests across three targets asserted nothing at all because of these two facts together, and now assert the measurements.

Bridge and Swift only: no kernel patch, no OCCT.xcframework rebuild.

The point-to-curve projection family finally has one answer for “there isn’t one” (#500)

Curve2D.parameterAtPoint(_:) was a fifth Geom2dAPI_ProjectPointOnCurve construction that #413’s unification never reached, and it had invented a third failure convention, worse than either of the two #413 replaced. Where a point has no projection at all (one beyond the ends of a bounded curve, or a circle’s centre, which is equidistant from every point on it), it returned the curve’s own firstParameter: a real parameter inside the curve’s own domain, indistinguishable from a genuine result. Whether that was right depended only on which end you fell off.

The 3D side turned out to be worse, and the audit’s own “adjacent, systemic” note undersold it. Curve3D.parameterAtPoint(_:) and Curve3D.closestParameter(to:) are two public spellings of the same computation, each with its own GeomAPI_ProjectPointOnCurve, and they disagree: one answers firstParameter, the other 0. On a curve trimmed to [3, 8], 0 is not even in the domain. Both old tests used a curve starting at parameter 0, where the two answers coincide, which is how the disagreement survived. There was no shared 3D helper at all: the 2D side got one in #413, the 3D side never did.

let seg = Curve3D.line(through: .zero, direction: SIMD3(1, 0, 0))!.trimmed(from: 3, to: 8)!

// Before: same question, three answers, none of them sayable as "no projection".
seg.parameterAtPoint(SIMD3(100, 0, 0))     // 3.0  the far end of the curve
seg.closestParameter(to: SIMD3(100, 0, 0)) // 0.0  outside the domain entirely

// After.
seg.nearestParameter(to: SIMD3(100, 0, 0)) // nil
seg.nearestParameter(to: SIMD3(5, 2, 0))   // 5.0

Not source-breaking. Curve2D.nearestParameter(to:) and Curve3D.nearestParameter(to:) are new and return Double?; all three old spellings remain as deprecated shims. Their behaviour changes in the no-projection case only: they now return .nan, the one Double that is not a legitimate parameter on some curve, instead of three different plausible-looking values. Code that was reading a real answer reads the same real answer.

Bridge-side, OCCTCurve2DParameterAtPoint now routes through occtNearestProjectionOnCurve2d (five entry points, one construction), and the 3D side gains the occtNearestProjectionOnCurve3d it never had, shared by OCCTCurve3DNearestParameter and OCCTExtremaLocateOnCurve’s full-range fallback. OCCTCurve3DClosestParameter is gone.

Curve3D.projectPoint(_:precision:) is deliberately not folded in: it runs ShapeAnalysis_Curve::Project, a different algorithm that always answers by adjusting to the curve’s ends. A test now pins that distinction so a later pass does not “unify” two things that genuinely compute differently.

Three cross-reference index entries in OCCTBridge.h were corrected along the way. The staleness the audit blamed for the miss was real, and worse than reported. Geom2dAPI_ProjectPointOnCurve listed four of its five entry points; GeomAPI_ProjectPointOnCurve listed exactly one function, which does not use it (OCCTCurve3DProjectPoint calls ShapeAnalysis_Curve), and none of the five that do; and ShapeAnalysis_Curve named three functions that do not exist under those names.

Bridge and Swift only: no kernel patch, no OCCT.xcframework rebuild.

One free-bounds analyser, and the double-perform() bug the second one hid (#504)

ShapeAnalysis_FreeBoundsProperties was wrapped twice. The v0.49.0 family behind Shape’s five …FreeBound… methods rebuilt the whole analyser and re-ran Perform() on every single call, so a per-bound report cost one full free-bound search per bound; the v0.114.0 family behind FreeBoundsProperties analysed once and answered every query from that result. The two also took opposite index bases in the C layer, 0-based on one side and 1-based on the other for the same conceptual parameter, with neither declaration saying the other existed. Both Swift wrappers compensated correctly, so nothing was visibly broken; a third caller written against either C function by analogy with the other would have been off by one with no diagnostic.

Consolidating them turned up a real bug that only the newer family could reach. OCCT’s Perform() appends to its two result sequences and never clears them, and Init() does not clear them either; only the constructors allocate them. FreeBoundsProperties.perform() is public and @discardableResult, so calling it twice doubled every count and every notch:

let props = FreeBoundsProperties(shape: opened, tolerance: 1e-3)!
props.perform(); props.closedCount   // 2
props.perform(); props.closedCount   // 4   (before #504)
props.perform(); props.closedCount   // 6

The stateless family could not hit it, because each of its calls built a fresh analyser. It is latched in the bridge now, and perform() is optional as well as idempotent: every accessor runs the analysis on demand, so forgetting it no longer reads as “this shape has no free bounds”.

Not source-breaking. Shape.freeBoundsAnalysis(tolerance:), closedFreeBoundInfo, openFreeBoundInfo, closedFreeBoundWire and openFreeBoundWire keep their signatures and now run on the shared analyser. FreeBoundsProperties keeps all eight of its accessors and gains totalCount, info(_:at:) and wire(_:at:), which take a BoundKind (.closed / .open) and give the open side the ratio, width and notchCount only the closed side used to expose.

An out-of-range index is now a real answer rather than a guess. Shape’s info methods used to infer it from perimeter > 0, so a genuine zero-perimeter bound would have read as “no such bound”, and the FreeBoundsProperties accessors did not range-check at all, letting the index reach NCollection_Sequence::Value and come back 0 from a catch-all. Both are checked in the bridge against the sequence length, and both report it as nil (or 0, for the Double accessors).

Two OCCT behaviours are documented and pinned by tests, having been found while measuring this:

  • ratio is an aspect ratio, contour length over contour width: 2 for a 20×10 bound. Both OCCTBridge.h and Shape.FreeBoundInfo called it area / perimeter², which for that bound is 0.0556. OCCT solves it from the area and the perimeter, and leaves both ratio and width at 0 when that solve has no real root, which an exactly square bound hits by one ulp, sitting precisely on the branch boundary. So 0 means “not solvable”, not “degenerate contour”, and a square is the one fixture that must not be used to test either field.
  • Perform() is not a success signal. It returns DispatchBounds() | CheckNotches() | CheckContours(), and CheckNotches() returns true unconditionally, so it is true for a shape with no free bounds at all and for one that was never loaded, the opposite of its documented “False if fail or no free bounds are found”. IsLoaded() is what the bridge checks instead.

The C family is 18 functions down to 6. OCCTBridge.h’s cross-reference index entry for the class named OCCTShapeFreeBoundsAnalysis*, a prefix that has never existed anywhere in the codebase; it names the real family now. ShapeAnalysis_FreeBounds (the similarly-named sibling class behind Shape.freeBoundsClosedWires and friends, easy to conflate and genuinely different) gains the index entry it never had.

Bridge and Swift only: no kernel patch, no OCCT.xcframework rebuild.

One continuity decoder per vocabulary, not nineteen (#490)

Continuity reached OCCT as a plain integer through 19 separate decoders: seven independently-named static helpers (14 copies across five .mm files), six switches written inline in the function that needed them, and one dead copy kept alive only so a stub could take its address to silence an unused-static warning. They disagreed, and not hypothetically, #433 already shipped a broken fill from exactly this, and two more pairs were still live:

  • Shape.bsplineRestrictionAdvanced vs Shape.bsplineRestriction. Both drive a ShapeCustom_BSplineRestriction through BRepTools_Modifier (the static ShapeCustom::BSplineRestriction the plain entry point calls is itself just that), but the advanced one read its argument as a GeomAbs_Shape ordinal, so 2 asked for C1 where its sibling asked for C2. Worse than a mismatch: of the seven values that reading advertised, only 0, 2 and 4 ever worked, ShapeCustom_BSplineRestriction returns a null shape for G1, G2, C3 and CN, so continuity3d: 1 silently failed every call. The parameters are now ParametricContinuity, matching the sibling exactly, with a deprecated Int overload that decodes the same way and says so.
  • Surface.splitSurfaceByContinuity vs Surface.splitByContinuity (found while auditing this issue, not named in it). Both wrap ShapeUpgrade_SplitSurfaceContinuity; criterion: 2 asked one for C1 and the other for C2, an observable difference in the returned split counts.

There are exactly three vocabularies, each now decoded in exactly one place (OCCTBridge_Internal.h) and named after the Swift enum that feeds it: SurfaceContinuity (geometric constraint order, 0=G0/1=G1/2=G2), ParametricContinuity (0=C0…3=C3) and the analysis order the LocalAnalysis_* junction analysers speak in both directions (a GeomAbs_Shape ordinal, 0=C0/1=G1/2=C1/3=G2/4=C2). All three saturate at the top of their own vocabulary, replacing three different out-of-range fallbacks (GeomAbs_CN, GeomAbs_C2, GeomAbs_C1), the same invalid integer used to mean different things depending only on which entry point received it. The analysis-order ceiling is measured, not arbitrary: asking LocalAnalysis_* for C3 or CN leaves every predicate reporting true, so C2 is the strictest question those classes can answer.

One user-visible consequence beyond the three divergences, for callers passing a raw Int outside the documented range: that input used to land on whatever fallback the local copy happened to carry, which for most of them was a valid, working continuity (usually C2), so an out-of-contract call quietly succeeded. It now saturates to CN, and the Approx* consumers fail on CN, so Curve3D/Curve2D/Surface.approximated(continuity: 99) returns nil where it previously returned a C2 approximation. Deliberate, a request the operation cannot honour should not silently become a different request, and covered by test. Values inside each documented domain are unaffected.

Also from this pass, all measured against the pinned kernel and now documented and tested rather than left to be rediscovered: the GeomConvert/Geom2dConvert Approx* family accepts C0/C1/C2 only (AdvApprox throws above C2, surfacing as nil), while the PointsToBSpline family accepts the whole ladder without failing; Curve3D.approximate(points:)’s reference page documented the wrong vocabulary entirely (the analysis order, which that call has never used); and BRepGraph.setEdgeRegularity is a stub that always returns false and never reads its continuity argument, its only test discarded the result and asserted nothing, so that had gone unnoticed since the OCCT 8.0.0 GA upgrade. Now asserted, documented, and tracked for resolution in #513.

Bridge-only plus doc/signature changes: no kernel patch, no OCCT.xcframework rebuild.

Two upstream OCCT null-context SIGSEGVs, patched and filed (#484)

Auditing every ShapeFix_Face call site turned up two unpatched, never-filed crashes of the same class as #317: ShapeFix_ComposeShell::Perform(), ShapeFix_ComposeShell::SplitEdges() and ShapeUpgrade_WireDivide::Perform() dereference their ShapeBuild_ReShape context unconditionally, and that context is null unless the caller made an optional SetContext() call. A plain 4-edge planar square face crashes both classes 100% of the time. Both are the odd ones out in their own package: ShapeUpgrade_FaceDivide::Perform(), their only in-kernel driver, self-creates a context and hands it down, and nine other healing classes carry the same guard.

Carried as Scripts/patches/0017-* and filed upstream as OCCT#1409 (repro) / OCCT#1410 (fix). Verified via the override-link technique: both no-context cases SIGSEGV before and complete after, and the with-context path is byte-identical before and after (BREP dump hash plus topology counts, planar and cylindrical). Takes effect at the next xcframework rebuild; nothing regresses in the interim because the bridge already sets a context at both call sites. Reproducer and writeup: Scripts/repro/484-null-reshape-context/.

Face.fixed(tolerance:) now heals what it claims to (#484)

OCCTFaceFix was the fourth ShapeFix_Face construction in the bridge and the only one the #317 pass missed, it built a bare fixer with no context, so the fixes that record replacements silently did nothing (and on any kernel without Scripts/patches/0005 it was exposed to the #317 null-deref). On the raw #317 shape, no context yields a BRepCheck-invalid face with no apex edge; with a context it is valid. Well-formed faces are unaffected.

Shape.connectedFaces(tolerance:): every shell, not just the first (#484)

The function had zero test coverage repo-wide. Writing it surfaced a first-of-N defect of the #439/#442/#443 family: only the first shell an explorer yielded was connected and the rest were dropped, so a compound of two boxes came back with 6 faces instead of 12. Every shell is now processed and the results reassembled through the shared occtSolidBodiesToShape helper, a single-shell input still returns a bare shell, and the nil-on-failure contract is unchanged.

Two stale cross-reference index entries corrected (#484)

OCCTBridge.h’s index mapped ShapeFix_Face → OCCTShapeFixFace and ShapeFix_FaceConnect → OCCTShapeFixConnect*. Neither symbol exists anywhere in the codebase, so anyone using the index to find every ShapeFix_Face call site for a #317-class re-audit got zero hits, which is how the unpatched fourth site above went unnoticed. They now name the real symbols: OCCTFaceFix, OCCTFaceFixer* and the two OCCTShapeCreateFaceFromSurfaceWire* functions; and OCCTShapeFixFaceConnect, a single function rather than a family.

New Scripts/check-bridge-index.py checks every index entry against the real symbols and exits 1 on any mismatch. Its first run showed the two #484 entries are not isolated: 139 of the index’s 418 symbol references name symbols that exist nowhere in Sources/. Filed as #510 rather than fixed here, each stale entry needs its real call site identified, and inventing a plausible name for a class that has no wrap would be worse than leaving the entry visibly broken.

Analytical conversion: one path per converter class, and the result no longer aliases its input (#492)

GeomConvert_CurveToAnaCurve and GeomConvert_SurfToAnaSurf each had two wrapper families, added eight releases apart, making the identical OCCT call and then disagreeing about the answer. The v0.30.0 curve wrapper hardcoded the curve’s own parameter range and discarded newFirst/newLast/ Gap(); the v0.30.0 surface wrapper carried an “already analytical” guard its v0.78 sibling never grew. Five bridge functions now reach two shared helpers, occtCurveToAnalytical and occtSurfaceToAnalytical (OCCTBridge_Internal.h), and OCCTCurve3DToAnalytical / OCCTSurfaceToAnalytical are gone.

The behaviour fix, which the issue did not predict. Probing both converters against the pinned kernel showed they do opposite things with an already-analytical input, and only one wrapper family had been written for either. GeomConvert_SurfToAnaSurf always allocates (GeomConvert_SurfToAnaSurf.cxx:791-807), so the surface guard was dead code. But GeomConvert_CurveToAnaCurve returns the input handle itself, ComputeLine and ComputeCircle down-cast the input and return it, and for a Geom_TrimmedCurve it returns the basis curve the trim still holds. Both curve wrappers handed that shared curve to Swift as a separate Curve3D, so the two aliased one Geom_Curve and Curve3D.translate is in-place: translating the result of Curve3D.circle(...).toAnalytical() by 100 moved the source circle by exactly 100. Both helpers now detach the result with Copy(), so the guarantee holds for both classes rather than depending on which branch of which kernel version happens to allocate. The results are line/circle/ellipse and plane/cylinder/cone/sphere/torus, so the copy costs nothing.

The contract is now stated once and identical on both sides: an already-analytical input converts (gap == 0 exactly) rather than being rejected, the result is independent of the input, and null input, unrecognisable input and OCCT’s own throw (the bounded overload raises Geom_BSplineSurface::Segment on inverted UV bounds) are one failure outcome.

New: Curve3D.toAnalyticalWithGap(tolerance:), the full-range curve spelling that reports the gap, the counterpart of Surface.toAnalyticalWithGap(tolerance:), previously missing, which is why getting a curve’s gap meant switching wrapper families. No other public Swift signature changed.

New AnalyticalConversionContractTests (Tests/OCCTCurveTests) pins all of it: 12 tests, of which the two aliasing cases fail by exactly 100.0 against the pre-#492 bridge. It also gives toAnalyticalWithGap(tolerance:uMin:uMax:vMin:vMax:) its first coverage of any kind. Probe and writeup: Scripts/repro/492-analytical-conversion/. GeomConvert_CurveToAnaCurve and GeomConvert_SurfToAnaSurf also gained the cross-reference index entries they never had.

The 2D conic factories reject degenerate dimensions, like their siblings already did (#487)

Behaviour change. Curve2D.ellipseFromCenterDir, Curve2D.hyperbolaFromCenterDir and Curve2D.parabolaFromCenterDir now return nil for dimensions that cannot describe the curve they name, matching the direct factories (Curve2D.ellipse, Curve2D.hyperbola, Curve2D.parabola) they are geometrically identical to. Previously they had no precondition at all and returned a live, degenerate curve:

call before now
ellipseFromCenterDir(majorRadius: 0, minorRadius: 0) ellipse whose every point is its centre nil
ellipseFromCenterDir(majorRadius: 8, minorRadius: 0) ellipse with a zero minor radius nil
ellipseFromCenterDir(majorRadius: 5, minorRadius: -3) ellipse reporting MinorRadius() == -3 nil
hyperbolaFromCenterDir(majorRadius: 0, minorRadius: 0) degenerate hyperbola nil
hyperbolaFromCenterDir(majorRadius: 6, minorRadius: 0) degenerate hyperbola nil
parabolaFromCenterDir(focal: 0) parabola collapsed to a line nil

Valid input is unaffected: both families still build the same curve, verified pointwise. Equal ellipse radii stay valid, and a hyperbola with its minor radius larger than its major stays valid, since neither is degenerate.

This is the same gap #399 closed for the four 3D conics and #411 closed for the 2D circle. Neither pass reached the 2D ellipse, hyperbola or parabola, because the predicate had been copied rather than shared: #399 left four static inline helpers in OCCTBridge_Curve3D.mm, #411 added a byte-equivalent fifth (occtValidCircle2dRadius) in OCCTBridge_Geom2d.mm, and the 2D direct factories spelled the same conditions inline in six more places. All twelve now call one of four definitions in OCCTBridge_Internal.h. A conic’s radii do not depend on whether it lives in a plane or in space, so there is nothing for a 2D copy to say differently.

Worth recording for future audits of this kind: no precondition inside the OCCT library is load-bearing in this build. Every one is written as a *_Raise_if macro, and the pinned OCCT.xcframework is a Release build, where OCCT’s own BUILD_RELEASE_DISABLE_EXCEPTIONS (default ON) defines No_Exception and expands all of them to nothing inside OCCT’s translation units. That is why gce_MakeElips2d(ax, 5, -3) reports gce_Done: its own two checks do not cover that input, and gp_Elips2d’s check, which does, is not compiled. The bridge’s .mm files are built without that macro, so the identical constructor called from the bridge does throw. For the hyperbola and parabola cases the divergence was never an OCCT accept/reject asymmetry at all: OCCT accepts (0, 0) and focal == 0 through both routes, and rejecting them is entirely this bridge’s contract.

Six more OCCTBridge_Geom2d.mm sites build a 2D conic from caller dimensions with no precondition (OCCTConvertEllipseToBSpline2D, OCCTConvertHyperbolaToBSpline2D, OCCTConvertParabolaToBSpline2D, OCCTMakeEdge2dEllipse, OCCTMakeEdge2dEllipseArc, OCCTConic2dFromEllipse) and none of their downstream algorithms self-reject: a zero-radius ellipse yields a 5-pole BSpline, an edge reporting IsDone(), and six zero conic coefficients respectively. Filed as #514 rather than swept in here; two of them have no nil channel to report a rejection through and each needs its own contract decision.

One result vocabulary for measured continuity, not three encodings (#485)

OCCTCurve3DContinuity / OCCTCurve2DContinuity / OCCTSurfaceContinuity and their *GetContinuity siblings wrapped the identical Geom*::Continuity() call but reported it through two incompatible numeric schemes. C0 was the only class the two agreed on:

class GetContinuity (real GeomAbs_Shape) Continuity (hand-written switch)
C0 0 0
G1 1 −2
C1 2 1
G2 3 −3
C2 4 2
C3 5 3
CN 6 99

Neither doc comment described either scheme correctly. GetContinuity’s claimed 0=C0, 1=C1, 2=C2, 3=C3, 4=CN, 5=G1, 6=G2, which is not what a static_cast of GeomAbs_Shape produces; Continuity’s omitted its own −2/−3/99 values entirely. Both wrong comments had been copy-pasted into the Swift layer.

Behaviour change. Curve3D.continuityOrder, Curve2D.continuityOrder and Surface.surfaceContinuityOrder now report the real GeomAbs_Shape ordinal. A C2 curve that answered 2 answers 4; a CN curve that answered 99 answers 6; a G1 curve that answered −2 answers 1. All three are deprecated in favour of continuityClass. Any threshold check of the form continuityOrder >= someOrder needs revisiting, that idiom compared two different encodings, which is the defect this family invited.

Behaviour change (C API only). OCCTCurve3DContinuity, OCCTCurve2DContinuity and OCCTSurfaceContinuity returned -1 for a null argument and now return 0, matching the *GetContinuity convention they delegate to. 0 is not distinguishable from a genuine C0 measurement; a caller needing to tell “null” from “C0” must null-check before calling. This follows the family’s existing convention rather than inventing a fourth sentinel, and all three declare _Nonnull arguments, so passing null was already a contract violation. No Swift API is affected, Curve3D/Curve2D/Surface cannot hold a null handle reference.

  • Bridge: the three switch bodies were byte-identical to each other and each duplicated its GetContinuity sibling’s one-line body. All three now delegate to that sibling, which also picks up the .IsNull() handle guard the Continuity family was missing: they checked only the wrapper pointer, then dereferenced the inner Handle, which the wrapper’s own default constructor (OCCTCurve3D() {}) leaves null, so this was reachable, not theoretical, and a null Handle deref is an OS signal the surrounding catch (...) cannot intercept. The C declarations are retained for ABI compatibility.
  • Swift: new top-level ContinuityClass is the shared result vocabulary, the third contract Continuity.swift already documented after #398 but had only half-implemented. Surface.Continuity becomes a deprecated alias of it (raw values unchanged), and Curve3D / Curve2D gain continuityClass; they previously had no typed form at all. ContinuityClass is Comparable by increasing smoothness, and adds derivativeOrder and satisfies(_:) so a continuity floor can be checked without comparing raw values across vocabularies. g1/g2 correctly satisfy no parametric order.
  • Tests: 17 across OCCTCurveTests, OCCTGeom2dTests and OCCTSurfaceTests. The pre-existing coverage only ever asserted >= 0 against CN-continuous primitives, which both encodings satisfy, so none of it could catch this. The new tests pin the real ordinals, compare the two properties on the same object, and reach the G1 class no earlier test could, an offset curve over a C0-but-tangent-continuous BSpline basis, which is the only route to a G1 measurement in the Geom hierarchy (Geom_BSplineCurve itself only ever reports C0…C3 or CN, so GeomAbs_Shape.hxx’s own “G2: for BSpline curves only” comment is also wrong).

One batch grid-evaluation family, not three generations per type (#486)

Curve3D, Curve2D and Surface each had three generations of “evaluate at N parameters” bridge functions, 15 in total, no shared helper between any of them, each hand-rolling its own parameter-pack loop and its own result-unpack loop. The two Surface entry points had drifted onto opposite UV layouts as a direct result:

  wrote its header comment said
OCCTSurfaceEvaluateGrid (v0.29.0) outXYZ[(iv * uCount + iu) * 3], V-major “row-major (u varies fastest)”
OCCTGridEvalSurfaceD0 (v0.111.0) xs[iu * vCount + iv], U-major “row-major”

“Row-major” says nothing about a UV grid, where either parameter can be the row. Nine duplicate bridge functions are removed, and the surviving six (OCCTCurve3DEvaluateGrid/D1, OCCTCurve2DEvaluateGrid/D1, OCCTSurfaceEvaluateGrid/D1) now share one parameter-packing helper and one definition of the surface grid index (occtSurfaceGridIndex, U-major), so the layouts cannot drift apart again.

New: Surface.evaluateGridD1(uParameters:vParameters:) and SurfaceGridD1. This finishes for the D1 path what #404 did for D0: results are indexed .at(u:v:) instead of arriving as a flat array whose major order you have to know.

let grid = surface.evaluateGridD1(uParameters: us, vParameters: vs)
let sample = grid.at(u: 2, v: 0)
let normal = simd_normalize(simd_cross(sample.d1u, sample.d1v))

Deprecated (all still work, each forwarding to its canonical sibling):

Deprecated Use instead
Curve3D.evalBatchD0(params:), Curve3D.gridEvalD0(params:) Curve3D.evaluateGrid(_:)
Curve3D.evalBatchD1(params:), Curve3D.gridEvalD1(params:) Curve3D.evaluateGridD1(_:)
Curve2D.evalBatchD0(params:), Curve2D.gridEvalD0(params:) Curve2D.evaluateGrid(_:)
Curve2D.evalBatchD1(params:), Curve2D.gridEvalD1(params:) Curve2D.evaluateGridD1(_:)
Surface.gridEvalD0(uParams:vParams:) Surface.evaluateGrid(uParameters:vParameters:)
Surface.gridEvalD1(uParams:vParams:) Surface.evaluateGridD1(uParameters:vParameters:)

The evaluateGridD1 spellings label the derivative tangent, where the deprecated ones labelled it d1. No public API is removed and no signature changes, so no source break.

Behaviour change: a failed evaluation returns an empty result, not zeroes. The v0.110/v0.111 bridge functions returned void: on a failure their Swift callers could not detect (null or unsupported geometry, an exception inside the evaluator) they wrote nothing, and the wrapper returned a full-length array of default-initialised SIMD3(0, 0, 0) as if evaluation had succeeded. All six survivors return the number of points written, and every wrapper now returns an empty array or an empty grid instead.

Behaviour change: evalBatchD0/evalBatchD1 now use the batch evaluator. Those four methods had regressed to calling Geom_Curve::EvalD0/EvalD1 (and the 2D equivalents) once per parameter, bypassing the GeomGridEval_Curve batch path that evaluateGrid had used since v0.29.0. They now forward there, so results can differ from the old per-point loop by ~1e-13 on a BSpline (measured against a ground-truth C++ comparison; analytic curves such as circles agree exactly).

Behaviour change (C API only). OCCTSurfaceEvaluateGrid now writes a U-major buffer (outXYZ[(iu * vCount + iv) * 3]), matching OCCTSurfaceDrawMesh, OCCTSurfaceEvaluateGridD1 and the Swift SurfaceGrid. Surface.evaluateGrid used to transpose the old V-major buffer while unpacking and no longer does, so its SurfaceGrid output is byte-for-byte unchanged, but any direct consumer of the C bridge must swap its index formula. OCCTGridEvalSurfaceD1 was renamed OCCTSurfaceEvaluateGridD1 and reshaped to the family’s interleaved-triple buffers.

Surface’s two transform families now share one gp_Trsf builder (#488)

OCCTBridge_Surface.mm carried the same gp_Trsf-construction switch seven times: once inline in each of the six immutable functions (OCCTSurfaceTranslate, Rotate, Scale, MirrorPlane, MirrorPoint, MirrorAxis) and once more as a standalone buildTrsf3D that only the in-place OCCTSurfaceTransform dispatcher used. All seven now route through the one buildTrsf3D, matching what #416 did for Curve3D, which that issue explicitly flagged for Surface and never applied.

The triplication had already caused a divergence. OCCTSurfaceTransform guarded only if (!surface) and then dereferenced surface->surface unconditionally, while all six of its siblings in the same file check s->surface.IsNull() first. That is the identical gap #416 fixed on OCCTCurve3DTransform. It now guards both. No live path reaches it with a null internal handle today (every call site null-checks the OCCT maker result before assigning), so this is a latent crash closed, not an observed one; a null Handle deref here would be an uncatchable SIGSEGV per the #345 precedent, not a caught C++ exception.

New SurfaceTransformFamilyParityTests (Tests/OCCTSurfaceTests) asserts the two families produce identical geometry for identical input across all six transform kinds, on a sphere and on a Bezier surface. The Bezier case matters because the analytic surfaces all take Geom_ElementarySurface::Transform, which just moves an axis placement, while a Bezier transforms every pole. Nothing had checked the two families agreed before, for Surface or, until #416, for Curve3D.

Writing that suite turned up a dead test: SurfaceTransformTests.transformBezierSurface (Tests/OCCTMathTests) built its surface from Curve3D.line, but OCCTSurfaceBezierFill2 down-casts its inputs to Geom_BezierCurve and returns nullptr for anything else, so the test got nil back and skipped its entire body through if let without ever calling translate. It now uses real Bezier boundaries and asserts the surface actually moved.

One skeleton behind the three edge-list fillet entry points, and the radius precondition one of them never had (#489)

Behaviour change, one call shape. Shape.blendedEdges(_:) now returns nil when any radius in the batch is non-positive or NaN, which is the contract filleted(edges:radius:) and filleted(edges:startRadius:endRadius:) already applied to theirs:

call before now
blendedEdges([(0, 2.0), (99999, -5.0)]) shape filleted on edge 0 only, reported as success nil
blendedEdges([(0, 0.0)]) nil nil
blendedEdges([(0, -5.0)]) nil nil
blendedEdges([(0, 2.0), (1, 0.0)]) nil nil

Only the first row changes, and measuring that is what narrowed the finding: a non-positive radius that reached OCCT was already reported as failure, because BRepFilletAPI_MakeFillet::Add(r, edge) with r of 0, a negative r, or NaN does not throw and does not build a wrong shape, it fails IsDone() (ground truth in Scripts/repro/489-fillet-radius-validation/). The case that escaped was a bad radius paired with an out-of-range edge index: the bounds check dropped the pair before the radius was ever used, so the batch built from the remaining edges and reported success for a request that was never fully honoured. Valid input is unaffected, pinned by a test that a uniform per-edge radius list produces the same volume as the uniform entry point. An out-of-range index with a valid radius is still skipped, unchanged.

OCCTShapeFilletEdges and OCCTShapeFilletEdgesLinear (OCCTBridge_Modeling.mm) and OCCTShapeBlendEdges (OCCTBridge_Healing.mm) were three hand-maintained copies of one loop: same TopExp::MapShapes edge map, same 0-based index bounds check, same Build/IsDone/Shape triad, same catch (...), differing only in the radius each edge gets. That is how the guard came to exist in two of them and not the third. All three now share occtShapeFilletEdgeList and the occtValidFilletRadius / occtValidFilletRadii predicates in OCCTBridge_Internal.h, so the next piece of hardening this family needs lands once. OCCTShapeFilletEdges also picks up the null-result check the blend function already had.

A fourth copy of the same loop turned up in OCCTShapeHistoryFromFilletEdges, which the finding did not name: it cannot use the full skeleton, because it keeps its builder alive to hand back a BRepTools_History over it, so the loop is split out as occtFilletAddEdges and shared at that level. It had no radius precondition either, nor does its single-edge sibling OCCTShapeHistoryFromFilletEdgeVariable; both now apply the same one, as do their Swift wrappers Shape.filletedWithFullHistory(radius:edges:) and Shape.filletedWithFullHistory(edge:startRadius:endRadius:). No observable change for either: both take a single radius (or radius pair) that is either valid or rejected outright, with no per-element pairing for an index skip to hide.

Bridge-only: no kernel patch, no xcframework rebuild, nothing filed upstream, since Add() reporting failure through IsDone() is OCCT’s documented BRepBuilderAPI_MakeShape contract.

The cross-reference index entry for BRepFilletAPI_MakeFillet named only the OCCTShapeFillet* prefix, so four of its call sites were unreachable from the index: OCCTShapeBlendEdges (the one this issue is about), OCCTShapeFuseAndBlend, OCCTShapeCutAndBlend and the OCCTFilletBuilder* family. That is the #484 failure mode again, an audit by symbol name finding fewer sites than exist, and it is how this family’s copies stayed out of view. The entry now names all of them; Scripts/check-bridge-index.py still reports the same 139 pre-existing stale entries (#510), none of them new.

Two family-level inconsistencies were found and deliberately left alone, filed as #520: the other two BRepFilletAPI_MakeFillet edge-list functions disagree with these three about what an edge index means and what an invalid one does. OCCTShapeFilletEvolving takes 1-based indices (documented as such on EvolvingFilletEdge.edgeIndex) and rejects an out-of-range one, and OCCTShapeFilletVariable takes a 0-based index and also rejects. Reconciling them changes public API behaviour and needs its own decision, not a drive-by in a dedup fix.

One GeomConvert_Approx* run per type, not two that disagree (#491)

Curve3D and Surface each wrapped the same OCCT approximation class twice, approximated returning the fitted BSpline, approxWithDetails returning it plus OCCT’s own diagnostics, and each pair had drifted. Both now run through one shared bridge helper per type, so the detailed entry point differs from the plain one only by carrying maxError/isDone/hasResult.

Three divergences resolved, plus one that turned out to be paper-only:

  • Surface: PrecisCode 0 vs 1. OCCTSurfaceApproximate passed 0 as GeomConvert_ApproxSurface’s eighth constructor argument and OCCTGeomConvertApproxSurface passed 1, with no comment either side. It is a real algorithm knob, not a reserved value: it reaches AdvApp2Var_Context’s iprecis, where lesparam turns it into the Jacobi degree and the initial per-axis sample count that seed the fit. Both now pass 0. Chosen on measurement over 72 bounded cases (8 surface families x 6 tolerances, plus C0/C1 and maxDegree 10 variants): the two codes never disagreed on IsDone and produced the same knot/pole layout in 71 of 72, but a different maxError in all 72, smaller with 0 in 64 of them, and in the one layout-differing case (an offset sphere at tolerance 1e-5) 0 met the requested tolerance with 27x15 poles where 1 needed 27x23. A caller who states a tolerance wants the lightest surface that meets it. OCCT itself splits along that same line: the two sites that re-check MaxError() against a tolerance they must honour pass 0 (ShapeCustom_BSplineRestriction, ShapeConstruct), and the six that never look at it pass 1 (GeomConvert_1 twice, ShapeUpgrade_UnifySameDomain, GeomFill_Sweep, GeomLib, BRepOffset_Offset). That census is #573’s correction of this one, which listed a commented-out BRepFill_Sweep site and missed BRepOffset_Offset.
  • Surface: default continuity C2 vs C1. Surface.approxWithDetails defaulted uContinuity and vContinuity to .c1 while Surface.approximated defaulted to C2, so the two no-continuity-argument calls fitted to different smoothness and returned different surfaces (15 vs 16 U poles on a sphere at 1e-3). Both now default to C2.
  • Both: the null-handle guard. OCCTGeomConvertApproxCurve/Surface checked only the outer wrapper pointer, while their plain counterparts also checked the OCCT handle inside it. A null Geom_Curve/Geom_Surface reaching GeomAdaptor_* is an uncatchable SIGSEGV in this Release kernel, where OCCT’s own Standard_NullObject precondition is compiled out. Both now check both.
  • Curve3D: IsDone() vs HasResult(), the audit’s headline claim, and it does not reproduce. The header documents these as different questions, so OCCTCurve3DApproximate’s IsDone() gate looked like it would reject a completed-but-over-tolerance fit that OCCTGeomConvertApproxCurve’s HasResult() gate returns. It cannot: GeomConvert_ApproxCurve copies both flags off AdvApprox_ApproxAFunction, whose only HasResult-without-IsDone path is an ErrorCode = -1 assignment upstream has commented out (AdvApprox_ApproxAFunction.cxx:550, // for now ErrorCode=-1;). With that line dead the two accessors are equal for every input, which is why gating on IsDone() never actually rejected anything, a circle fitted with one segment at degree 3 against a 1e-9 tolerance reports maxError 5.1 and isDone true. The gate is unified on HasResult() anyway: it is what OCCT’s own curve-conversion sites use (GeomConvert.cxx, GeomToIGES_GeomCurve.cxx, GeomFill_Profiler.cxx), what both surface entry points already used, and the only gate under which approxWithDetails’ isDone: false diagnostic means anything.

So Curve3D.approximated is unchanged in behaviour and Surface.approximated is unchanged in output; what changes observably is Surface.approxWithDetails, which now returns the same surface Surface.approximated does instead of a slightly different one. Neither divergence had any test coverage in either direction, no test anywhere called both entry points on the same input. New Issue491Curve3DApproxParityTests (Tests/OCCTCurveTests) and Issue491SurfaceApproxParityTests (Tests/OCCTSurfaceTests) assert success, geometry, pole/degree counts and reported maxError agree across both entry points on 9 curve and 12 surface requests spanning the starved, over-tolerance and unreachable-tolerance cases.

Each .mm also had two copies of the request-side int32_t → GeomAbs_Shape mapping (a static intToContinuity for the detailed entry point, the identical switch spelled inline in the plain one); each file now has one. The wider census of that conversion across the bridge is #513’s, not this issue’s.

Building the parity tests surfaced an unrelated upstream defect, filed as #522: GeomConvert_ApproxSurface asked for GeomAbs_C0 can collapse a direction to degree 1 and return a surface deviating by the input’s own diameter while reporting IsDone() and a maxError five orders of magnitude too small (a full sphere at C0 comes back as a straight line across its longitude). It predates #491, both entry points hit it identically, and unifying them neither causes nor fixes it. The surface parity suite keeps C0 in its request set, both entry points must still return the same surface there, and they do, but excludes it from the “reported error describes the returned surface” assertion, with a comment to drop that exclusion when #522 is fixed.

Curve3D.interpolatePeriodic delegates instead of reimplementing, and gains tolerance: (#493)

Behaviour change. Curve3D.interpolatePeriodic(points:) with exactly 2 points used to return nil; it now returns a valid out-and-back periodic loop. OCCTInterpolatePeriodic was a second, independent GeomAPI_Interpolate call site alongside OCCTCurve3DInterpolate, and the two had drifted: the periodic one rejected count < 3 where the general one rejects only count < 2, so the same 2-point input reached OCCT through interpolate(points:closed:tolerance:) with closed: true and not through interpolatePeriodic. Confirmed by running it, not by inspection: the general entry point returns a closed, periodic curve over 0...20 for two points, and OCCT builds it without complaint.

This is #412’s fix, applied to the 3D sibling it never touched. The 2D pair was fixed in v1.17.0 and the 3D pair was left with the defect verbatim, including the fix comment’s own description of it. OCCTInterpolatePeriodic is now return OCCTCurve3DInterpolate(points, count, true, 1e-6);. The C ABI is unchanged, and Curve3D.interpolatePeriodic delegates to interpolate(points:closed:tolerance:) rather than flattening its own buffer.

Additive: Curve3D.interpolatePeriodic(points:tolerance:) gains a tolerance: parameter defaulted to the 1e-6 it used to hardcode, so the bare call is unchanged. The tolerance was previously unreachable, and it is not decorative: OCCT treats points closer together than the tolerance as coincident and refuses the interpolation, so with two points 1e-3 apart the default succeeds and tolerance: 1e-2 returns nil. That case is now asserted rather than assumed.

New Curve3DInterpolatePeriodicParityTests (Tests/OCCTCurveTests) ports the 2D suite #427 added (default-tolerance parity, tolerance reachability, the 2-point floor, single-point rejection) and adds the tolerance-changes-the-outcome case and a non-planar loop, which checks the shared path is not flattening z. The 2-point test was run against the unfixed code first and fails there, so it covers the defect rather than describing it. The only pre-existing coverage of the 3D function was a 4-point square asserting != nil (Tests/OCCTMiscTests) plus one use as a fixture for an unrelated test, which is why the drift survived #412.

One defeaturing skeleton, and the fuzzy tolerance that never existed (#497)

Five bridge functions ran BRepAlgoAPI_Defeaturing, each with its own copy of the same SetShape/AddFaceToRemove/Build/IsDone sequence, and the copies had drifted apart on every precondition: one silently skipped an out-of-range face index while another failed the call, one dereferenced its faces array with no null check (a crash no catch (...) could have caught), and only two checked the result shape for null. OCCTBridge.h’s own cross-reference index listed one of the five, so a maintainer using the index to find the existing wrap would have found a third of it.

Two of the five were the same function twice: OCCTShapeDefeature (v0.118.0) and OCCTDefeatureWithTolerance (v0.114.0) differed only in that the older one called SetFuzzyValue. On the Swift side both were spelled defeature(faces:)-callable, defeature(faces:) and defeature(faces:tolerance: Double = 0): and Swift’s overload resolution sends a call site that omits tolerance: to the exact-arity overload every time, so the fuzzy path was unreachable without naming the argument. Confirmed against this package, not argued from the rules: deprecating one overload and rebuilding showed the existing test at OCCTModelingTests.swift:4134 binding to it.

The tolerance it was hiding does nothing. BRepAlgoAPI_Defeaturing::Build forwards the input shape, the faces to remove, the history flag and the parallel flag to the BOPAlgo_RemoveFeatures that does the work, and nothing else; the fuzzy value inherited from BOPAlgo_Options is stored, readable back through FuzzyValue(), and never consulted. Its own header says so in the class comment (“the other options of the base class are not supported here and will have no effect”). Measured as well as read: identical BREP output at every fuzzy value from 1e-7 to 100, against a BRepAlgoAPI_Cut control that the same magnitudes collapse to an empty shape, see Scripts/repro/497-defeaturing-fuzzy-inert/. Not an upstream defect, so nothing to file or patch; the wrapper was OCCTShapeDefeature under another name and is gone.

The four remaining entry points, withoutFeatures(faces:), defeature(faces:), defeaturedWithFullHistory(faces:) and withoutSmallFaces(minArea:), now share one skeleton and one set of preconditions, and the index names all four.

Behaviour change. A face index that does not belong to the shape now fails withoutFeatures(faces:) instead of being dropped from the request. Dropping it returned a shape that still carried the feature the caller asked to remove, indistinguishable from a successful removal; the two index-addressed siblings already failed on it. defeature(faces:tolerance:) is deprecated (it forwards, and its tolerance was never read); calls that omit tolerance: were already reaching the tolerance-free path and are unaffected.

Fix: four arc-length samplers wrote past the end of the caller’s buffer (#501)

GCPnts_UniformAbscissa sizes its own parameter array at nbPoints + 5 and fills it as far as the arc-length walk runs, so NbPoints() is not bounded by the count that was asked for, and GCPnts_QuasiUniformAbscissa inherits that for every curve which is neither Bezier nor BSpline, because it forwards to GCPnts_UniformAbscissa for those. Four bridge functions were handed a buffer sized from the requested count and then filled it with NbPoints() values: OCCTCurve3DQuasiUniformAbscissa (Curve3D.quasiUniformParameters(count:)), OCCTCurve3DDrawUniform (Curve3D.drawUniform(pointCount:)), OCCTCurve2DDrawUniform (Curve2D.drawUniform(pointCount:)) and sampleAdaptorUniform (CompCurve/EdgeCurve sampleUniform(count:)).

Reproduced against the shipped functions with sentinel-guarded buffers: on an ellipse with major radius 1e6 and minor radius 1e-3, 22 of the first 59 point counts overshoot by one, for 48 overflowing calls across the three curve entry points. The trigger is rounding: the walk stops ~1.6e-8 in parameter short of the end against an epsilon of ~1e-13, so the sampler takes one more step. Well-conditioned geometry does not do it, which is why this survived from v0.31.0: a line, a circle at radii from 1e-6 to 1e7, a 5x2 ellipse, hyperbola, parabola, Bezier, BSpline, offset and trimmed curves were all clean across counts 2..200.

The two drawUniform entry points did not corrupt memory quietly. They crashed the process. Their Swift wrappers unpack the buffer by index using the count the bridge returned, so a returned pointCount + 1 reads three (or two) elements past the end of the Swift Array and hits its bounds check: Fatal error: Index out of range. quasiUniformParameters uses prefix(n), which clamps, so there the only symptom was the heap write itself.

Clamping alone would not have been the fix. The surplus point is the curve’s end parameter, so truncating the tail leaves the distribution stopping short of the curve, which is precisely what OCCTGCPntsQuasiUniform (Edge.quasiUniformParameters(count:)), the one member of the family that already clamped, had been doing silently on every overshooting call. The shared occtSamplerKept/occtSamplerIndex helpers keep the first capacity - 1 samples and the sampler’s own last one.

Also fixed: Shape.uniformAbscissa(pointCount: 0) returned five parameters instead of nil. Both samplers document nbPoints >= 2 and enforce it with a Raise_if, which the Release kernel compiles out (No_Exception, #487), and OCCTUniformAbscissaByCount/ByCountRange had no count precondition of their own. Below 2 the algorithms misbehave rather than fail: GCPnts_QuasiUniformAbscissa(bezier_or_bspline, 0) builds an NCollection_HArray1 over the empty range (1, 0) and writes element 1 of it: an uncatchable SIGSEGV, one missing guard away on three of the six entry points. occtValidSampleCount is now applied at all of them, so count/ pointCount below 2 returns empty rather than reaching OCCT.

Reproducer and the full measured tables: Scripts/repro/501-quasiuniform-buffer-overflow/.

The orphaned duplicate the index was hiding, and eight corrected entries (#501)

OCCTBridge.h’s index mapped GCPnts_QuasiUniformAbscissa → OCCTCurve3DQuasiUniformParams, a symbol that has never existed. Chasing the real one turned up two: OCCTCurve3DQuasiUniformAbscissa (v0.31.0) and OCCTGCPntsQuasiUniformCurve (v0.75), byte-for-byte the same sampling of the same Curve3D through the same two OCCT classes. The v0.75 one had no caller in Swift or in the tests and never had; it is removed. Its maxParams bound (the one thing the older spelling lacked, and the reason the older spelling was overflowing) is folded into the survivor. No Swift API changes: OCCTGCPntsQuasiUniformCurve was C-level only and never reachable.

Index entries corrected alongside it: the whole GCPnts block (UniformAbscissa and UniformDeflection both pointed at OCCTCPntsUniformDeflection*, which wraps a different OCCT class, CPnts_UniformDeflection, now given its own entry; AbscissaPoint named one 2D function out of eleven; TangentialDeflection had no entry at all), BRepGProp (two of three symbols missing their Get), ShapeCustom_DirectModification (missing its Custom), and BRepOffset_SimpleOffset, which named OCCTShapeSimpleOffset (a function that wraps BRepOffset_MakeSimpleOffset, a different class, now indexed separately) while the real OCCTBRepOffsetSimpleOffset was absent. Scripts/check-bridge-index.py goes from 139 stale entries to 134; the remaining 134 are #510.

Worth noting for #510: the two GCPnts_Uniform* entries passed the checker. It verifies that a named symbol exists, not that it wraps the class the entry claims, so a mis-attributed entry is invisible to it. 139 is a floor, not the count.

One BRepLib::BuildCurves3d entry point, not three, and one default instead of two (#498)

Behaviour change. Shape.buildCurves3d(tolerance:)’s default moved from 1e-7 to 1e-5. Callers who pass a tolerance explicitly are unaffected; callers who omit it get OCCT’s own default for the operation. Pass tolerance: 1e-7 to keep the old value.

The bridge had three C entry points for one operation. Two of them, OCCTBRepLibBuildCurves3dForShape (v0.114.0) and OCCTBRepLibBuildCurves3dAll (v0.122.0), declared ~1700 header lines apart, had byte-identical bodies: the same overload, the same two arguments. The third, void OCCTShapeBuildCurves3d, wrapped BuildCurves3d’s no-tolerance overload, which turns out to be return BuildCurves3d(S, 1.0e-5); and nothing else, so it was the same call again, with the success flag discarded. All three now go through OCCTBRepLibBuildCurves3dForShape.

Nothing connected the two Swift wrappers written over the duplicate symbols, so their defaults drifted 100x apart, and the cost was real. On a pcurve-only edge on a cylinder:

// Before, same operation, same input, no arguments, two different curves.
edge.buildCurves3d()      // edge tolerance 1e-07, 8 poles
edge.buildCurves3dAll()   // edge tolerance 1e-05, 7 poles, curve up to 2.6e-6 away

// After, one call behind both names.
edge.buildCurves3d()      // edge tolerance 1e-05
edge.buildCurves3d(tolerance: 1e-7)   // the tighter curve, asked for

1e-5 was chosen over 1e-7 because it is OCCT’s own default for both the parameter and the no-tolerance overload, it is what two of the three entry points already used, and the tolerance is not only an approximation bound: BRepLib::BuildCurve3d also makes it the rebuilt edge’s tolerance floor, using the requested value rather than the deviation it achieved (the line that would have used the measured deviation is commented out in the kernel). 1e-7 therefore claims an accuracy the approximator is asked but not required to deliver.

  • Shape.buildCurves3dAll(tolerance:) is deprecated and forwards to buildCurves3d(tolerance:).
  • Shape.allEdgePolylinesIndexed(deflection:maxPointsPerEdge:) now spells its 1e-5 out, which is exactly what the no-tolerance overload it used to call did. Its behaviour is unchanged.
  • buildCurves3d returning false is documented for the first time: it means “at least one edge failed”, and the edges that succeeded are still modified. The void entry point discarded that signal, on the one path (bulk discretisation of arbitrary imported shapes) where partial failure is likeliest.

Both pre-existing tests called the operation on a box, where every edge already has a 3D curve, so OCCT returns true at its first line and computes nothing, a tolerance of 42 passes them just as 1e-7 did, which is why the drift survived. One is now an explicit early-return test (asserting the edge tolerances and curves are untouched, with tolerance 42); the other is the deprecated-spelling guard. New BuildCurves3dTests (Tests/OCCTTopologyTests) covers the case where the operation actually works: an edge carrying only a pcurve on a cylinder. Run against the unfixed code first, the default-value and cross-wrapper-agreement tests fail there, so they cover the defect rather than describing it.

One sub-shape enumeration, not one per accessor (#502)

Shape.solids/solidCount, shells/shellCount and wires/wireCount walked a bare TopExp_Explorer, which yields one entry per occurrence in the topology tree. subShapes(ofType:)/subShapeCount(ofType:), along with faceCount, edgeCount, vertexCount, uniqueSubShapeCount(ofType:) and the face(at:)/edge(at:) indexed accessors, built a TopTools_IndexedMapOfShape through TopExp::MapShapes, which keeps one entry per distinct sub-shape. Two answers to one question, in two hand-written traversals, with no test anywhere comparing them.

The two are not independent primitives: TopExp::MapShapes(S, T, M) is a TopExp_Explorer walk piped into the map (TopExp.cxx:34-45), so the deduplicated sequence is the explorer’s sequence with later repeats removed: same order, no index moved. Every sub-shape accessor in the bridge now reads one enumeration (occtMapSubShapes), and the six TopExp_Explorer entry points behind the typed accessors are gone, along with a seventh (OCCTShapeGetEdgeCount) that had no caller and disagreed with OCCTShapeGetTotalEdgeCount two declarations away.

Behaviour change, in the deduplicating direction. solidCount/shellCount/wireCount and solids/shells/wires now count distinct sub-shapes. Measured against the pinned kernel (Scripts/repro/502-subshape-traversal-dedup/), the old and new answers are identical for every ordinary shape tried (the primitives, a hollow solid’s two shells, two distinct bodies, two placements of one body, a sewn stack, a compsolid), and differ only where one sub-shape is reachable from two parents:

shape before after
Shape.compound([box, box]) (the same Shape twice) 2 solids 1 solid
the same shell handed to two solidFromShells calls 2 shells 1 shell
one wire used to build two faces 2 wires 1 wire
a face and its own reverse in one shell 2 faces 1 face

Deduplication is by TopoDS_Shape::IsSame, which compares the location as well as the geometry, so two placements of one body are still two solids and instanced assemblies are not collapsed. It ignores orientation, so a sub-shape embedded forward in one parent and reversed in another is one sub-shape.

Two illustrations of why this is the answer the whole API should have been giving: a plain 10mm box has 24 edge occurrences over 12 edges and 48 vertex occurrences over 8 vertices, and edgeCount already reported 12; and Shape.faces() (still an explorer walk, see #541) can hand back a face face(at:) cannot address, because their indices came from different enumerations.

One tolerance behind every local-properties entry point, and a cusp that returned NaN (#494)

GeomLProp_SLProps/GeomLProp_CLProps/GeomLProp_CLProps2d take a Resolution argument their own headers describe as “the linear tolerance (it is used to test if a vector is null)”. It is not a comparison tolerance: it decides whether a derivative counts as null, and so whether the tangent, normal and curvature exist at all at a point. The bridge passed three different values across 28 construction sites, Precision::Confusion() (1e-7) from the canonical Surface/Curve3D/Face/ Edge/Curve2D entry points, 1e-10 from the Local* family, and 1e-6 from OCCTGeomLProp{CL,SL}Props. #405/PR #425 fixed this for three Surface entry points; its audit was Surface-only and never inventoried the Local* family or the Curve3D side, so the same defect survived in the siblings. All 28 now construct through shared helpers in OCCTBridge_Internal.h (occtLocalPropsResolution, occtSurfaceLocalProps, occtCurveLocalProps, occtCurve2dLocalProps), so the value is stated once and no site can drift again.

Note the direction, which #405’s own framing had backwards for this case: a smaller resolution is the more permissive one, because the null test is derivative.SquareMagnitude() > resolution * resolution. The Local* family’s 1e-10 was three decades more willing to call a degenerate point well-conditioned than the canonical family, not less.

Measured divergences, all now gone. On a cubic Bezier whose first two poles sit 1e-8 apart, at u = 0:

  • Curve3D.localCurvature(at:) returned 6.7e15 where curvature(at:) returned Double.greatestFiniteMagnitude (OCCT’s infinite-curvature sentinel), 293 orders of magnitude apart.
  • localTangent(at:) returned (1, 0, 0) where tangentDirection(at:) returned (0.707, 0.707, 0). Not a precision difference: the two resolutions disagree about which derivative is the first significant one, and OCCT derives the tangent from that one, so the two reported genuinely different directions.
  • localNormal(at:) returned a vector where normal(at:) returned nil.

On a cone with radius: 0 at v = 1e-8, Surface.localCurvatures(u:v:) reported a defined mean curvature of -8.66e7 at a point curvatures(u:v:), gaussianCurvature(atU:v:), meanCurvature(atU:v:) and principalCurvatures(atU:v:) all reported undefined. Same for a sphere approaching its pole. Shape.curveLocalProperties/surfaceLocalProperties (the 1e-6 pair) disagreed with Edge.curvature3D(at:) and Face’s curvature entry points the same way.

A separate live defect found while probing those gates, affecting the canonical family too: OCCT returns RealLast() from Curvature() to mean infinite curvature, at a cusp, where the first significant derivative has order 2, e.g. a Bezier whose first two poles coincide. IsTangentDefined() is still true there, and the sentinel trivially passes any “is the curvature big enough to invert” test, so it flowed straight into CentreOfCurvature(). That is worse than an exception: LProp_CurveUtils::Curvature() returns the sentinel without assigning the curvature field ComputeCentreOfCurvature then divides by, leaving it 0.0, so the caller got (nan, inf, nan) reported as a successfully computed point. Curve3D.centerOfCurvature(at:), localCentreOfCurvature(at:), Curve2D.centerOfCurvature(at:), Edge.centerOfCurvature3D(at:) and Shape.curveLocalProperties were all affected; each now returns nil/no centre, via a shared occtCurveCurvatureIsInvertible predicate that rejects the sentinel as well as a below-resolution curvature. Normal() was never exposed, it rejects the sentinel explicitly and raises.

Also hardened while in these functions: the six Local* bridge entry points dereferenced their Geom_Curve/Geom_Surface handle without a null check their canonical siblings all make (an uncatchable SIGSEGV in this Release kernel, where OCCT’s own precondition is compiled out), and three functions called Curvature() before establishing IsTangentDefined(), relying on a raise that goes through LProp_NotDefined_Raise_if, live in the bridge’s own translation units, compiled out inside the OCCT build. Neither is reachable through today’s Swift API; both are now consistent with the rest of the family.

New LocalPropsParityTests (Tests/OCCTAnalysisTests, 11 tests) asserts definedness and value agreement between each entry point and its counterpart across well-conditioned points, the old 1e-10-vs-1e-7 window, and genuinely degenerate points, plus the cusp regression and a blanket “no local-properties entry point ever returns a non-finite number” sweep. Each was run against the pre-fix bridge to check it discriminates the fix rather than merely passing alongside it: 5 fail there (both 1e-10 window tests, the cusp regression, the non-finite sweep, and the cusped-edge test that covers the 1e-6 pair), and 6 pass both before and after as intended controls. The issue’s own review of existing coverage was accurate, no test anywhere compared a Local* function against its sibling, and none drove a degenerate parameter through one.

Two API additions fell out of writing those tests, both exposing state the bridge already had: OCCTCurveLocalProps gains curvatureInvertible, because Shape.curveLocalProps(at:) decided whether its normal/centerOfCurvature were filled in by re-testing curvature > 1e-10 in Swift, a copy of a bridge-side literal that no longer exists, and one that would have gone stale in the other direction after this change. SurfaceLocalProperties gains curvatureDefined, which the bridge struct has always carried but the Swift wrapper dropped: its four curvature values are non-optional and all zero where curvature is undefined, so a caller had no way to tell a cone’s apex from a flat point. Both are additive; neither struct has a public memberwise initializer.

One documentation correction fell out of writing the parity tests, unrelated to the tolerance: Surface.localCurvatureDirections(u:v:) was documented as returning nil “for umbilic points (where curvature is constant)”, which reads as covering a sphere. OCCT’s umbilic test is |maxCurv - minCurv| < Epsilon(maxCurv): one ULP, not a geometric tolerance. A plane always qualifies (both curvatures are exactly zero), but an analytically-umbilic sphere qualifies only where the two computed values round to the same Double: on a sphere of radius 3 it does at v = 0, 0.3, 0.5 and -0.7 and does not at v = 1, where they differ by exactly one ULP. Nothing changed here, IsUmbilic() takes no resolution, but the docs now say what it does, and the test asserts the asymmetry on a plane rather than on a sphere, where it would be flaky.

Bridge-only: no kernel patch, no OCCT.xcframework rebuild. Probes and full writeup at Scripts/repro/494-lprop-resolution/.

Not changed, and tracked separately as #529: 19 BRepLProp_SLProps/ BRepLProp_CLProps constructions still pass a literal 1e-6. They are a different, adaptor-based class family asking the same question of a face rather than a surface, and several feed face orientation decisions rather than curvature reporting, so they need their own validation.

One path parser, not two that disagree on what an extension is (#499)

PathParser wrapped TDocStd_PathParser and OSDPath wrapped OSD_Path: two OCCT classes answering the same questions behind identically-named Swift methods, each with its own test pinning its own format and neither comparing itself to the other. PathParser now forwards to OSDPath, whose bridge family is the single path-parsing implementation; TDocStd_PathParser is no longer wrapped, and its four bridge functions are deleted.

Silent behaviour change, in two places, prompted by a deprecation warning at every call site rather than a compile error. SEMVER.md records the exception:

PathParser.fileExtension("model.step")     // was "step"        now ".step"
PathParser.trek("/home/user/model.step")   // was "/home/user"  now "/home/user/"

The formats were the reported divergence. Measuring both classes across 19 inputs (Scripts/repro/499-path-parsing-divergence/) found four cases where TDocStd_PathParser was not differently formatted but wrong, all of which the forwarding fixes:

input PathParser, before now (= OSDPath)
/home/user/model name "", directory "" (Parse() returns early when there is no dot) name "model", directory "/home/user/"
/home/user/.config nil from every accessor (Split past the end of the string, caught by the bridge) ext ".config", directory "/home/user/"
/home/a.b/model name "a", ext "b/model" (the last dot anywhere wins, separators ignored) name "model", ext ""
/home/üser/mødel.step name "mødel" name "mødel"

The issue predicted the opposite of that last row: that OSD_Path’s documented ConstructionError for characters outside ' '...'~' would make every OSDPath method return nil for a non-ASCII path, while TDocStd_PathParser’s TCollection_ExtendedString handled it. OSD_Path.cxx never throws that error (the header documents a constraint the implementation does not enforce), and the mangling was on the TDocStd_PathParser side, in the bridge: TCollection_ExtendedString(const char*) defaults to theIsMultiByte = false, so UTF-8 input was read one byte per character and re-encoded on the way out.

OSDPath.trek(_:) is not a filesystem path. OSD_Path::Trek() returns OCCT’s portable directory syntax, where / becomes | and .. becomes ^. /home/user/m.step gives "|home|user|", and ../up/f.txt gives "^|up|". The Swift doc comment said only “Get the directory trek from a path”, and the method had no test of any kind. It now says what it returns and points at the new OSDPath.folder(_:), which gives the real directory ("/home/user/") and is what PathParser.trek forwards to. folder/file recompose to the input; trek never could.

Also: OSD_Path and OSD_Environment gained the cross-reference index entries neither ever had, and the four OCCTOSDPath* string accessors, four copies of construct-read-strdup, share one helper.

One skeleton behind the cylindrical-hole family, one set of drilling preconditions, and the two drills kept apart on purpose (#496)

The audit read Shape.drilled(at:direction:radius:depth:) (BRepPrimAPI_MakeCylinder + BRepAlgoAPI_Cut, with a bounding-box-diagonal length for through holes) as a cruder reimplementation of the BRepFeat_MakeCylindricalHole family, and proposed folding the first into the second. Measuring both against the pinned kernel says that would not be a refactor (Scripts/repro/496-drill-hole-contracts/): six of thirteen probed requests change answer, all in the direction of losing work that currently succeeds.

  • Perform is an infinite cylinder, both ways along the axis. The origin anchors the axis; it is not where the hole starts. Drilling “down” from a plate’s own midplane, the boolean path removes the 10mm below the origin and Perform removes all 20.
  • PerformUntilEnd is not forward-only either, despite documenting itself as “every hole located after the origin”. It uses LocalizeAfter(0.) only to pick the starting intersection and then resets backwards to the entry face.
  • PerformBlind refuses a depth that leaves the stock (BRepFeat_HoleTooLong) where the boolean path treats the overshoot as harmless and drills through. Passing a depth comfortably past the far face is a normal way to drill through without computing the thickness.
  • The feature drill wants a solid. A shell or a face is InvalidPlacement for every mode but Perform.

So the two stay. What they now share is the one thing they genuinely should, the preconditions on a drilling request, and drilled’s documentation says plainly which to reach for and why.

The precondition that was missing from both. OCCTShapeDrillHole guarded radius <= 0; the feature family guarded nothing. Neither caught a positive but sub-tolerance radius, and the kernel is a Release build, so its own *_Raise_if checks are compiled out by No_Exception (#487). Measured: a radius of 0 or 1e-14 makes every BRepFeat_MakeCylindricalHole mode report BRepFeat_NoError and return a shape identical to the input, same volume, same six faces, no material removed. Both families now share occtValidDrillRadius (must exceed Precision::Confusion) and occtValidDrillDirection; the latter is the guard #496 flagged, which the feature family had only by accident, via gp_Dir’s throw landing in its own catch (...).

Four bodies to one. The four OCCTBRepFeatCylindricalHole* functions were the same Init/Perform*/Status/Build body four times, three differing by a single Perform* line and the fourth being the third with the Build deleted. They are now one occtBRepFeatCylindricalHole skeleton in OCCTBridge_Internal.h behind two bridge entry points (6 → 2 C functions), which is also what makes the status honest.

New: Shape.CylindricalHoleExtent, and a status query that answers about the extent you asked for. cylindricalHoleStatus wrapped Perform no matter what the caller was about to drill, so it was a false green: a radius wider than the whole solid is .noError for through-all and .invalidPlacement for thru-next. And BRepFeat_HoleTooLong is written in exactly two places in the kernel, both inside PerformBlind, so .holeTooLong was a Swift enum case no public spelling could produce. cylindricalHole(axisOrigin:axisDirection:radius:extent:) and cylindricalHoleStatus(axisOrigin:axisDirection:radius:extent:) take the extent; status is .noError if and only if the matching drill returns a shape.

The extent enum also fills the two unwrapped modes: .untilEnd (PerformUntilEnd, the stock-bounded through hole callers reach for .throughAll expecting) and .range(from:to:) (the ranged Perform). The four v0.71.0 methods are unchanged in behaviour and now forward onto the unified spelling; no existing call site needs editing.

A wrong answer found and documented, not fixed here. PerformUntilEnd and the ranged Perform both end in an nbparts >= 2 branch that keeps exactly one part of the cutting tool. Across a stack of two solids the kept part can be one that intersects nothing, and the operation then reports BRepFeat_NoError while removing no material at all, having only imprinted the cylinder’s faces. Perform and the boolean drill both cut every body. Kernel behaviour and out of scope here, filed as #532 and since fixed, see the entry below. The ranged overload’s parameters were also measured rather than assumed: the window selects which entry/exit face pair bounds the hole, it does not trim the cut, so a window strictly inside one body still drills through all of it.

Issue496CylindricalHoleTests (Tests/OCCTModelingTests), 13 tests. Seven were run against the unmodified bridge first (the #489 lesson) and five of those already passed, the divergences the fix documents rather than repairs. Both repairs were confirmed by injecting the old behaviour back and watching the suite fail. A thirteenth, added during review, sweeps blind depths tight around the plate’s exact exit-face parameter: the status query short-circuits before Build() runs, so it can only ever see PerformBlind’s a priori HoleTooLong check, never Validate()’s post-hoc one, the two are independent computations that could in principle disagree right at the boundary. Measured: they agree, closing the gap rather than finding a live divergence.

FilletBuilder’s three radius laws are keyed by an edge, and now check that they are (#505)

Behaviour change, and it is a bug fix. FilletBuilder.getBounds(contour:edge:), getLaw(contour:edge:) and setLaw(contour:edge:law:) used to answer about a (contour, edge) pair that names nothing: a contour index of 0, an edge from a different contour, an edge in no contour at all. They now return nil / false for those.

Not source-breaking. getBounds and getLaw gain an edge: Edge spelling, and the edge: Shape one they had is deprecated and forwards to it.

The audit finding was a type inconsistency: OCCTFilletBuilderSetLaw took an OCCTEdgeRef while its two siblings took an OCCTShapeRef, so two of the three had to downcast with TopoDS::Edge while the third did not, and a caller holding an Edge (the type addEdge, removeEdge, setRadius and contour(for:) all take) had to convert it to a Shape to read a law and hand back the original to write one. The pre-existing test carried the round trip and a comment about it. All three OCCT functions take a const TopoDS_Edge&, so SetLaw was the one that had it right and the framing of the finding (four functions in the doc group agree, SetLaw is the outlier) counted the wrong majority: Generated/Modified/IsDeleted sit in the same group and genuinely take a const TopoDS_Shape&, since any sub-shape can be asked about. They keep OCCTShapeRef.

Making the three identical is what surfaced the real defect. They all resolve the edge through ChFiDS_FilSpine::ChangeLaw(E), which asks ChFiDS_Spine::Index(E) for the edge’s position in the contour’s spine, gets 0 for an edge that spine does not hold, and uses it anyway: ElSpine(0) → FirstParameter(0) → abscissa->Value(0 - 1). That read has no live bounds check, because OCCT’s *_Raise_if macros are compiled out of the pinned Release build, and neither does ChFi3d_FilBuilder’s own Value(IC). Measured on a 10×10×10 box (Scripts/repro/505-filletbuilder-edge-type/):

// Two contours. Before #505, every one of these answered, and reported success.
builder.getBounds(contour: 1, edge: edgeOfContour2)   // (-5, 15) and contour 1's law
builder.getBounds(contour: 1, edge: edgeInNoContour)  // (-5, 15), same
builder.getBounds(contour: 0, edge: edgeOfContour1)   // (-5, 15), same
builder.setLaw(contour: 1, edge: edgeInNoContour, law: law)   // true, and it overwrote contour 1's law

// After: nil, nil, nil, false.

Only the low side of the contour range leaked, since OCCT does check IC <= NbElements(), so 2 and 99 already returned false. Contour(E) is the same IsSame walk over the same spines that Index(E) is about to do, so it decides exactly this question, and Add() rather than Build() populates it, which makes it valid before a build as well as after. One helper, occtFilletContourHoldsEdge, applies it to all three.

Three things the same measurements pin, now documented on the API for the first time:

  • A constant-radius contour has no law. OCCT throws “no law on constant edges” rather than handing back a flat one, so all three report no law. The old test’s comment called this a “crash”; it is a Standard_DomainError, and the bridge’s catch (...) already turned it into nil.
  • The law needs a spine split. Before build() there is nothing to read, and simulate(contour:) is the other way to get one. After it the range is the spine’s own [0, 10] rather than the post-build [-5, 15], which runs past both ends of the edge.
  • setLaw does not reach the geometry. getLaw reads the new law back, but a build() after it reports IsDone() == 1 and hands back the unfilleted input shape, and setting the law before the first build (via simulate) then building produces the volume the original addEdge radii give, not the one the new law asks for. Upstream behaviour; the doc comment says so.

setLaw had no test at all before this: the one function of the three whose type was right was also the uncovered one. Issue505FilletBuilderEdgeTypeTests (Tests/OCCTModelingTests) covers all three through a single Edge value, the round trip that used to need a conversion each way. Run with the guard removed, the suite fails every time, but with 11 to 18 recorded issues across five runs: the unguarded answer is whatever the out-of-bounds read finds, so it is another contour’s law on one run and a “no law on constant edges” throw on the next.

Bridge and Swift only: no kernel patch, no OCCT.xcframework rebuild.

Fix: LawFunction.knotSplitting reported at most 100 splits, whatever the law had (#481)

LawFunction.knotSplitting(continuityOrder:) read into a fixed 100-entry buffer and returned however many entries came back, so a law with more splits than that reported exactly 100 with nothing to say the rest had been dropped. Its sibling knotSplitParameters(continuityOrder:), added alongside it in #403 over the same Law_BSplineKnotSplitting analyzer and the same law, reads the true count and retries at that size. The two therefore disagreed about how many splits a law has: measured on a degree-3 law with 150 knots of multiplicity 3, knotSplitting returned 100 indices and knotSplitParameters returned 150 parameters.

The issue’s proposed fix, applying the sibling’s read-then-retry in Swift, could not work on its own. OCCTLawBSplineKnotSplitting returned min(maxIndices, nbSplits), the count it had written, so at a 100-entry first pass a truncated result is indistinguishable from a law with exactly 100 splits and there is nothing to size a retry from. The bridge function now reports the true split count even when the write was truncated, and fails with -1, matching OCCTLawBSplineKnotSplitParams in both respects. That is a C-layer contract change: a direct caller of the bridge that treated the return value as “entries written” now needs to clamp it, and one that treated 0 as failure now sees -1. OCCTBridge is not an SPM product, so no package outside this repo can be that caller.

Both bridge functions now share one occtWriteKnotSplits helper, generalised from #403’s occtWriteKnotSplitParams (which is now a thin wrapper over it), so the two cannot drift apart on their truncation contract again. This is the same defect and the same fix as Curve3D.continuityBreaks (#398, a fixed 256-entry buffer) and Surface.knotSplitting (#403).

Issue481LawKnotSplittingTruncationTests (Tests/OCCTCurveTests), 4 tests, run against the unmodified bridge first: the count-agreement test failed at 100 versus 150, which is the property the truncation broke.

OCCT.xcframework rebuilt: the #484 null-context guard is now in the kernel binary (#512)

A carried patch does nothing until the xcframework is rebuilt from source, so 0017 above was inert on merge. The kernel is now rebuilt from V8_0_0_p1 + all 19 carried patches: ShapeFix_ComposeShell::Perform(), ShapeFix_ComposeShell::SplitEdges() and ShapeUpgrade_WireDivide::Perform() no longer SIGSEGV when the caller never set a ShapeBuild_ReShape context.

No API change and no behaviour change through this wrapper. Both bridge call sites already set a context, so nothing here could reach the crash before or after; those workarounds stay in place, and retiring them is a later follow-up (the same PR1→PR2 pattern as #298/#341/#344/#349). What the rebuild buys is closing the crash for code that reaches those OCCT classes through a path this package does not control.

Verified against the rebuilt binary with no override-linked TUs. The earlier #484 evidence was all override-linked, which proves the patch compiles and works, not that it shipped. Both ctx=NO cases in repro_484_crash.mm complete where they were killed by SIGSEGV before, and all four ctx=yes fingerprints in repro_484_equivalence.mm are unchanged (now recorded as reference values in Scripts/repro/484-null-reshape-context/). Full swift test: 4842 tests in 1346 suites, clean. 0017 itself needs no ThreadSanitizer gate, being a null-handle guard on a single-threaded path, but the gate did run for the 0016 redesign below, which ships in the same rebuild.

Package.swift’s remote url:/checksum: pin and the kernel-patch list on the ## Current: line above are the release commit’s job, not this change’s: until then a consumer resolving the remote URL still gets the previously released kernel, while this checkout and every sibling repo path-depending on its Libraries/OCCT.xcframework get the new one. docs/guides/building-occt.md gained a “Shipping a rebuild” section covering that sequence, which until now existed only as hand-written checklists in issues. Patch 0016 (#374) also gained the Scripts/patches/README.md entry it never got. The rebuild carries 0018 (#555) and 0019 (#522) as well, both added after this entry was first written.

Storage_Schema’s scratch state becomes a member instead of a guarded global (#518)

Patch 0016 (#374) fixed two kernel races: Resource_Manager::Debug became std::atomic<bool>, and Storage_Schema::ICurrentData(), a function-local static Handle(Storage_Data) shared by every Storage_Schema in the process, was guarded by a new recursive mutex held across Write() and every internal accessor. Reviewing our upstream PR OCCT#1399, maintainer gkv311 pointed out the second half should not need a lock at all: the handle can just be a mutable Handle(Storage_Data) field on the class.

That holds up. Every Storage_Schema in the kernel is constructed locally by its caller and used only there (PCDM_StorageDriver::Write, and PCDM_ReadWriter_1 at three sites); none is cached or shared, and Storage_CallBack::Add/Write/Read all take the driving schema as an argument, so every callback re-entry lands back on the same instance. The state was per-instance data masquerading as a global. 0016 now deletes the static and the mutex in favour of the field, and also drops the static from AddPersistent()’s TCollection_AsciiString aTypeName scratch variable, a smaller hazard in the same class that the mutex had covered incidentally. Both ICurrentData() and ISetCurrentData() were private with no callers outside the class, so nothing observable changes.

Same correction, and the same lesson, as #341 to #363: a lock is the right tool only when the state is genuinely one shared resource. It is also strictly stronger on the failure #374 reported, since a throwaway schema built during an unrelated Open() can no longer reach an in-flight Write()’s data at all, where the mutex only stopped it from being a data race.

No OCCTSwift API or behaviour change, and the patch number stays 0016 (a corrected design, not a new fix), renamed to ...-Storage_Schema-per-instance-374.patch. Verified with the same #374 ThreadSanitizer harness the mutex version was verified against: 0 races and 0 save/load/verify failures at 8×50, 8×30 and 10×60, plus the full Scripts/tsan-stress.sh run gate (10 scenarios) clean. Ships in the same rebuild as #512 above. OCCT#1399 has since been updated to this design and is green on all 17 upstream CI jobs.

Two GCPnts point-count defects patched in the kernel (#555)

New carried patch 0018, in the arc-length samplers behind Curve3D.quasiUniformParameters(count:), Curve3D.drawUniform(pointCount:), Curve2D.drawUniform(pointCount:), Shape.uniformAbscissa and the sampleUniform(count:) family.

NbPoints() was not bounded by the requested count. GCPnts_UniformAbscissa sizes its parameter array at theNbPoints + 5 and walks until it reaches the end parameter or runs out of room, so a caller sizing its own buffer from the request could be handed more points than it asked for. GCPnts_QuasiUniformAbscissa inherited this for every curve that is neither Bezier nor BSpline. The cause is a tolerance mismatch: the walk terminates on a parametric epsilon derived from the curve’s largest derivative, which on an ellipse with major radius 1e6 and minor radius 1e-3 is about nine orders of magnitude too tight at the end, so the walk stops 1.557e-08 short, takes one more step and appends what is measurably a duplicate point (1.175e-10 away in 3D). Perform now also accepts a point that coincides with the end in 3D within the caller’s tolerance. Clamping the count instead would have dropped the exact end parameter and left the distribution stopping short of the curve.

A point count below 2 stored out of bounds. Both classes document theNbPoints >= 2 and enforce it with Standard_ConstructionError_Raise_if, which compiles to nothing in the shipped Release kernel (#487). GCPnts_QuasiUniformAbscissa’s Bezier/BSpline branch then allocated an empty array and unconditionally stored into index 1 of it: an uncatchable SIGSEGV, the same class as #263, #310, #317 and #318. Both classes now leave the object not done for such a count, so a request for zero points is answered with nothing rather than with a crash or with five parameters.

Shape.uniformAbscissa(pointCount:) and friends already rejected degenerate counts bridge-side after #501, and the buffer overflow was closed there too, so no OCCTSwift API changes behaviour here. What the patch buys is closing both for code that reaches those OCCT classes through a path this package does not control.

Measured across 17 curve types and counts 2 to 200 (6766 configurations): 232 lines change and they are exactly the 232 that were over-requesting, every other line byte-identical, and the last parameter still exactly the end on the changed ones. Reproducers, including the trap that a repro built without -DNo_Exception measures a kernel nobody ships: Scripts/repro/555-gcpnts-count-contract/. Filed upstream as OCCT#1417.

Surface approximation at C0 stops collapsing, and its reported error starts being true (#522)

New carried patch 0019, behind Surface.approximated(tolerance:continuity:) and Surface.approxWithDetails(tolerance:uContinuity:vContinuity:): and behind a good deal of the OCCT kernel besides.

A radius-10 sphere approximated at C0 and tolerance 1e-3 came back as a degree-1, 2-pole-in-U B-spline: a straight line across the full 2*pi of its longitude, deviating by the sphere’s own diameter of 20, while isDone said the tolerance was met and maxError said 1.07e-4. A bicubic Bezier at C0 collapsed to a 2x2 bilinear patch reporting 4.08e-15 at every tolerance from 1e-1 down to 1e-7, tightening the request changed nothing.

One line in AdvApp2Var_ApproxF2var::mma2ce1_ explains both. It partitions a single scratch allocation into seven buffers, ipt4 for XMAXJU (the maxima of the U Jacobi polynomials) and ipt5 for XMAXJV, then fills both from ipt5, leaving XMAXJU unwritten and in practice zero. Every truncation error the approximator computes is |coefficient| * XMAXJU(i) * XMAXJV(j), so a zero XMAXJU makes the interior error of every patch evaluate to exactly 0. From there: the tolerance test can never fire on the interior, maxError only ever describes the boundary iso-curves, and the degree-reduction search, asked for the lowest degree whose truncation error still fits, always answers with its floor, because every candidate scores 0. C0 is where that floor is low (a full sphere’s V-boundary isos are its two poles, one coefficient each), which is why C0 collapsed and C1/C2 did not. The misreported error was never specific to C0.

Across a 98-case sweep (7 surface families x all 9 (uContinuity, vContinuity) combinations of C0/C1/C2, plus C0/C0 at five tolerances), results whose real deviation exceeds the reported maxError by more than 10x go from 12 to 0, and those exceeding it at all from 17 to 1, the survivor a Bezier reproduced exactly, reporting 9.95221e-15 against a measured 9.96978e-15. Every reported error rises slightly, which is the interior contribution being counted for the first time. Degrees rise only where the collapse was happening: a cylinder trimmed in V still fits at degree 1 in V, because it is linear there.

GeomConvert_ApproxSurface is not a leaf. GeomFill_Sweep, BRepOffset_Offset, GeomLib, ShapeCustom_BSplineRestriction, ShapeConstruct and GeomConvert_1 all call it, ShapeCustom_ConvertToBSpline and ShapeUpgrade_UnifySameDomain reach it, and GeomPlate_MakeApprox drives the same approximator directly. Most request C1 or C2, so the collapse could not reach them, but the always-zero interior error could, and the healing paths reach C0 on purpose: ShapeConstruct::ConvertSurfaceToBSpline and ShapeCustom_BSplineRestriction both loop the requested continuity down to 0 on failure, then accept the result on MaxError() <= tol, and ShapeCustom_ConvertToBSpline starts at C0 for any offset surface (ShapeCustom_ConvertToBSpline.cxx:148) before handing off to the first of those. The two remaining mentions of the class, BRepFill_Sweep.cxx:1162 and BRepFill_Filling.cxx:712, are both inside comment blocks and are not callers. Follow-ups filed for what this means per consumer.

Tests/OCCTSurfaceTests/Issue491SurfaceApproxParityTests.swift’s maxErrorDescribesTheSharedFit had to exclude .c0 requests when it was written, because “sampled deviation <= reported maxError” failed there on OCCT’s own numbers. That exclusion is gone. Reproducers, the root-cause walkthrough and the before/after sweep transcripts: Scripts/repro/522-approx-c0-collapse/. Filed upstream as OCCT#1418.

New workflow: kernel-integration.yml validates a carried patch against the patched kernel (#585)

ci.yml’s macOS check always resolves Package.swift’s pinned, released OCCT.xcframework, since a clean checkout has no local Libraries/. A PR that carries a kernel patch not yet in a release and adds a regression test for its fixed behaviour therefore fails that check indistinguishably from a real regression: #519 (patches 0016(redesign)/0018/0019, closing #518/#522/#555) hit this exactly, its new #522 test reproducing the original bug’s own symptom against the stale kernel, needing a manual dig to tell “expected gap” apart from “the patch doesn’t work.” The old ci.yml comment already named the fix and never built it: “that’s kernel-rebuild.yml’s job, ~30-60 min” describes a workflow that never existed anywhere in this repo’s history.

kernel-integration.yml is that workflow, finally built. It triggers only on a PR/push touching Scripts/patches/** or Scripts/build-occt.sh, so ordinary PRs stay on the fast ci.yml path. actions/cache@v4 keys on a hash of the patch files plus the build script itself (so a pinned-OCCT version bump also invalidates it): the first run after a patch changes pays the full rebuild, every later run with the same patch set restores Libraries/OCCT.xcframework in seconds. Deliberately caches only the final xcframework, never the intermediate occt-build-*/occt-src trees, CMakeCache.txt bakes in the configuring checkout’s absolute path, which a fresh runner never reuses, so caching a half-built tree would just restore an unusable cache every run (the same gotcha docs/guides/building-occt.md already documents for resuming an interrupted local build). ci.yml’s own comment now points here instead of describing the rebuild as manual-only.


Release History

Unreleased: fix, zero-mass BRepGProp results were returned as successful answers (#609)

Version and date deliberately unset; whoever tags stamps them.

This entry contains source-breaking changes. They are held for the next major (SEMVER.md); do not tag this into a 1.x release.

GProp_GProps legitimately has zero mass: ask for volume properties of a face, or surface properties of an edge, and there is no such quantity. OCCT’s contract is that the caller checks Mass(), and passes OnlyClosed when it wants the volume integral to refuse an open surface rather than estimate one. The bridge never did either, across 22 user-facing entry points. #605 fixed the first two; this is the rest.

The wrong answer was not a recognisable zero. GProp_GProps seeds itself with gp_Pnt(0,0,0) transformed by the shape’s location, so a face moved to (100,200,300) reported exactly that, and moved again reported (200,400,600). No consumer could defend itself with if com == .zero:

face.centroid                            // was (0,0,0)       -> now nil
face.moved(dx: 100, dy: 200, dz: 300)!.centroid   // was (100,200,300)  -> now nil

And everything derived from a zero-mass framework was an artefact, not a zero. Measured:

sheet.radiusOfGyration(axisOrigin: .zero, direction: SIMD3(0,0,1))  // was NaN, now nil
sheet.principalAxes()      // was (0,0,1)/(1,0,0)/(0,1,0), math_Jacobi's identity basis; now nil
sheet.symmetryAxes()       // was 3 axes claiming SPHERICAL symmetry; now []
sheet.inertiaProperties()?.hasSymmetryPoint   // was true for every face, edge, wire and vertex

OnlyClosed = true also fixes a wrong answer for closed geometry. A compound of a 10x20x30 box plus one loose face reported 6857.14 where the box’s volume is 6000: the divergence integral runs over whatever faces it is given.

Shape.compound([box, someFace])?.volume   // was 6857.14, now 6000
openShell.volume                          // was 4800,    now nil

That closes the inconsistency #605 left behind, where open.volume was 4800 while open.properties()?.volume was nil for the same shape.

signedVolume is deliberately excluded, and is now documented as an orientation signal rather than a measurement. Reversing a surface negates the divergence integral whether or not the surface is closed (+4800 forward, -4800 reversed), so the sign is sound where the magnitude is not. Shape.sweep produces an open shell and normalises it through orientedForward() (#170), so routing this through the strict volume would have silently stopped normalising the exact case #170 was filed about. The full-suite run is what caught that; the ground truth is in the reproducer.

Behaviour changes without a compile error

API was now
volume the divergence integral over any faces given nil unless a closed shell encloses it
signedVolume -1 on an internal error 0, so orientedForward() stops reading an error as “reverse me”
volumeInertia, inertiaProperties() an artefact framework nil outside the volume domain
surfaceInertia, surfaceInertiaProperties() an artefact framework nil for a shape with no faces
symmetryAxes() 3 axes for any zero-mass shape []

Source breaks

API was now
Shape.centroid SIMD3<Double> SIMD3<Double>?
Shape.linearProperties() LinearProperties LinearProperties?
Shape.momentOfInertia() InertiaTensor InertiaTensor?
Shape.principalAxes() PrincipalAxes PrincipalAxes?
Shape.radiusOfGyration(axisOrigin:direction:) Double Double?
GeometryProperties.barycentre(_:) SIMD3<Double> SIMD3<Double>?
GeometryProperties.lineSegment(from:to:) a tuple an optional tuple, nil for coincident endpoints
GeometryProperties.circularArc(...) a tuple an optional tuple, nil for a zero normal
GeometryProperties.pointSetCentroid(_:), .weightedCentroid(points:weights:) centroid: SIMD3<Double> centroid: SIMD3<Double>?
CurveInertia, FaceSurfaceInertia, FaceVolumeInertia, MeshCinertResult, MeshPropsResult centerX/centerY/centerZ centerOfMass: SIMD3<Double>?
Shape.VinertGKResult.center SIMD3<Double> SIMD3<Double>?
ShapeMeasurements.faceCentroids [SIMD3<Double>] [SIMD3<Double>?]

The mass alongside each of those stays non-optional on purpose. A zero volume contribution from one face of a shell is a real answer a caller summing the decomposition needs; only the centroid is missing.

That rule applies to the per-element results, not to the whole-shape queries, and the difference is deliberate rather than an oversight. Face.surfaceInertia and Face.volumeInertia are summands of a decomposition, so they keep their area / volume of 0 and drop only the centroid. Shape.surfaceInertia, Shape.volumeInertia, inertiaProperties() and linearProperties() answer “measure this object”, where a zero mass means the measure does not apply at all, so the whole result is nil. The plain mass accessors are unaffected either way: Shape.surfaceArea still returns 0.0 for an edge and Shape.totalEdgeLength still returns 0.0 for a vertex, because a mass with no centroid attached to it needs no refusal.

GeometryProperties’ analytic members keep answering for a valid element measured over an empty range: GProp_CelGProps computes its centroid analytically, so an arc with u1 == u2 has a mass of 0 and a correct centre. Only inputs OCCT rejects return nil there.

Migration

Nothing changes for a solid. Outside the volume domain, ask the measure that applies: surfaceInertia for an area centroid, linearProperties() for a length centroid, vertices().first for a vertex position. For geometry that is closed but unsewn (mesh-derived imports, and every IGES import, which carries surfaces and no solid concept), sew before asking for a volume:

let imported = try Shape.loadIGES(from: url)
imported.volume                                  // nil: six loose faces are not a closed shell
Shape.sew(shapes: imported.faces().compactMap { Shape.fromFace($0) })?.volume   // 3000

BRep_Tool::IsClosed counts topological edge sharing, not geometric coincidence, so this is the one behaviour change with real teeth downstream. Both of our own IGES round-trip tests hit it.

volume’s nil does not distinguish “no closed shell” from “closed, but with its faces pointing inward”: a reversed solid has always returned nil there, since the accessor drops a negative. Sewing does not help the second case. A caller that needs to tell them apart should ask signedVolume, which answers for both, and normalise with orientedForward():

shape.volume                          // nil for an open shell AND for a reversed solid
shape.orientedForward()?.volume       // a number for the reversed solid, still nil for the shell

Downstream, tracked before the release train

  • SecondMouseAU/OCCTReconstruct#553 (P1). Builds solids from mesh data and gates on volume/signedVolume at ~25 sites, including ReconstructBuild.swift:591 where thin.shape.volume != nil guards a whole reconstruction tier with no isValidSolid beside it. signedVolume is unaffected by design; volume needs a sew on any path that does not already have one. A tier that stops firing will not fail a test on its own.
  • SecondMouseAU/OCCTDesignLoop#67 (P2). ProfileLift.swift:232 and PerpendicularBoolean.swift:134 do volume ?? abs(signedVolume), a fallback that was nearly unreachable and now converts “no volume” into the exact fabricated figure this fix removes.
  • SecondMouseAU/OCCTMCP#168 (P1, already open for #605, noted there). compute_metrics now omits volume / centerOfMass for a sheet body or unsewn import rather than reporting a figure. No code change needed; it already propagates nil.

Release-train checklist:

  1. Land OCCTReconstruct#553 and OCCTDesignLoop#67 before bumping either repo’s OCCTSwift pin past this release.
  2. Rebuild OCCTBridge.xcframework and bump its Package.swift URL and checksum in the release commit. Nine C symbol signatures changed in the public OCCTBridge.h (OCCTShapeGetVolume, OCCTShapeLinearProperties, OCCTShapeMomentOfInertia, OCCTShapePrincipalAxes, OCCTShapeRadiusOfGyration, OCCTShapeCentroid, OCCTGPropLineSegment, OCCTGPropCircularArc, OCCTGPropBarycentre), so a consumer building with OCCTSWIFT_BRIDGE_PREBUILT=1 against a stale prebuilt bridge gets a wall of compile errors (cannot convert value of type 'Double' to expected condition type 'Bool') until it is refreshed. Scripts/build-occtbridge.sh builds it. Per #512 the URL and checksum bump belongs to the release commit, not to this one.

No kernel patch and no OCCT.xcframework rebuild: the pinned OCCT binary is untouched, and only the bridge and Swift sources changed. Reproducer and full measured output in Scripts/repro/609-zero-mass/.

Closes #609


Unreleased: fix, centerOfMass returned the bounding-box centre (#605)

Version and date deliberately unset; whoever tags stamps them.

Shape.centerOfMass and Shape.properties().centerOfMass computed the midpoint of BRepBndLib::Add’s bounding box instead of calling BRepGProp, under a comment claiming GProp_GProps::CentreOfMass() “appears to return (0,0,0) for some shapes”. Wrong since v0.7.0 (2026-01-14) and wrong for every shape not symmetric about its bounding box: a plain cone reported (0, 0, 10) where its centre of mass is (0, 0, 5), and a 10-cube unioned with a 2-cube 20 units away reported 8.0 against an analytic 0.1587, off by a factor of 50. These were the only two bounding-box centroids in the bridge; centroid, volumeInertia, surfaceInertia, inertiaProperties(), linearProperties() and measure().faceCentroids were all already correct, so the API contradicted itself: cone.centerOfMass and cone.centroid disagreed by 2x.

Fixed by following BRepGProp rather than inventing a dispatch OCCT does not have. Ground truth against the pinned kernel (Scripts/repro/605-center-of-mass/) established three things:

  • An open shell is refused, not estimated. BRepGProp::VolumeProperties defaults to OnlyClosed = false, and the divergence integral over a surface enclosing nothing returns a number anyway: 4800 with a centroid 2.6 units adrift, for five faces of a 10x20x30 box. Both sites now pass OnlyClosed = true, matching OCCT’s own XCAFDoc_Centroid writer (XDEDRAW_Props.cxx) and the c flag on Draw’s vprops. Closing an open shell is the caller’s decision, so the API declines to guess one. Closedness is computed per shell by BRep_Tool::IsClosed rather than read from a cached flag, so a sewn-but-unflagged closed shell still counts, and a closed shell outside any solid counts too: the key is closedness, not ShapeType() == SOLID.
  • A zero-mass result is not a recognisable zero. VolumeProperties seeds its framework with gp_Pnt(0,0,0) transformed by the shape’s location, so a face at (100,200,300) reports a centre of mass of exactly that. Mass() is the only sound test, and both sites now use it: a face, wire, edge, vertex or open shell returns nil rather than a plausible-looking point.
  • The inertia tensor was never affected. It is referenced to the centre of mass (Iyy = 16666.67 for a 10-cube at x=20, against 416666.67 about the origin) and passes through unchanged. The defect was that ShapeProperties sat a COM-referenced tensor next to a field that was not the COM; that is now coherent.

Behaviour change for consumers. centerOfMass and properties() now return nil for shapes that enclose no volume, where they previously returned a bounding-box centre. Callers using Shape.centerOfMass as a positional key for a face, edge or vertex should move to vertices() for a vertex position, surfaceInertia for an area centroid, or linearProperties() for a length centroid, all of which were already correct. No API signature changed, so this is a change of value rather than a compile error.

Release train. Two consumers are affected and are filed, not fixed here. Land OCCTMCP#168 before bumping OCCTMCP’s OCCTSwift pin past this release: four vertex sites there read centerOfMass ?? .zero, and the ?? would swallow the new nil and collapse every vertex onto the origin without an error. OCCTReconstruct#552 is a re-run only, no code change expected.

Nine regression tests (Tests/OCCTAnalysisTests/CenterOfMassTests.swift), each on a shape whose bounding-box centre differs from its centre of mass, since every pre-existing assertion used a box or cylinder centred at the origin where the two coincide. Verified by injecting each wrong implementation in turn: the original bounding-box version fails 7 of the 9, OnlyClosed = false fails the open-shell test with the fabricated 4800, and dropping the Mass() guard fails 2.

Not an OCCT bug, so nothing filed upstream. Sibling defect #609 (zero-mass results returned as successful answers across seven other surfaces, including Shape.volume still reporting 4800 for an open shell) was filed from this investigation and is not addressed here. #605.

Unreleased: fix, a cancelled import could report .importFailed instead of .cancelled (#525)

Version and date deliberately unset; whoever tags stamps them.

OCCTImportSTEPRobustProgress set *outCancelled only at its own explicit UserBreak() checkpoints, so which error a cancelled import reported depended on which phase the cancellation happened to land in. A break during the transfer leaves TransferRoots reporting zero transferred roots, and that exit returned “failed” with the flag still false:

// Deadline expires while the transfer is still running
catch ImportError.importFailed("Failed to import: /tmp/part.step")   // was: for a readable file
catch ImportError.cancelled                                          // now

Found as a flake in #300’s own regression test, which set its deadline at 0.75 × a wall-clock measurement of a preceding uncancelled import: machine load, not the bridge, decided which phase the deadline fell in, and about 1 run in 9 fell in the transfer. Any caller whose deadline expires early reaches the same path.

Every failure exit below the indicator’s construction now reports cancellation if a break was observed, the zero-roots exit, a null shape, a non-Done status, and the catch (...) handler (which needed the indicator hoisted out of the try). Applied across all twelve *Progress entry points in OCCTBridge_IO.mm, not only the two robust importers, since they share the shape.

A second defect surfaced while probing the first: BridgeProgressIndicator::UserBreak() re-asked the caller at every checkpoint and believed the latest answer, so a caller that answers true once: a one-shot flag, an already-consumed Task.isCancelled, had that answer overwritten. OCCT aborted the phase, the next poll said “no break”, and the half-repaired shape came back as a success. The break is now latched (std::atomic<bool>, since OCCT documents UserBreak() as callable concurrently), which is what ImportProgress.shouldCancel always documented.

Both #300 regression tests were rewritten off the clock. That the repair phase lies inside the caller’s progress range is now checked by the silence that would follow the last progress report if it did not: measured at 1.3% (STEP) and 3.4% (IGES) of the call with the fix, 35–40% with the #300 defect reintroduced. That a cancellation there stops the repair is checked against the uncancelled run’s poll count, a count of work items, not a duration, and identical on a loaded and an idle machine. Progress names cannot substitute for the phase, tempting as they look: both readers run a ShapeFix_Shape of their own during the transfer, so Fixing face / Fixing edge / Update tolerances are already being reported from fraction ~0.09.

Bridge-only change: no OCCT kernel patch, no OCCT.xcframework rebuild; OCCTBridge.xcframework needs one since OCCTBridge_IO.mm changed. The previously flaky suites ran 12/12 clean; each new test was verified to fail against the defect it covers, re-injected one at a time.

Unreleased: fix, a refused FillingSurface.add still let build() return a face (#482)

Version and date deliberately unset; whoever tags stamps them.

#434 converged FillingSurface and Shape.fill(constraints:) onto one builder and one shared occtFillingAddConstraint, but left them disagreeing about what a refused constraint means, and the disagreement favoured the wrong outcome on the incremental API.

occtFillingAddConstraint refuses a constraint when a nominated support face carries no pcurve for its edge, which is routine on imported or sewn shapes. It does not call Add, so that constraint simply does not exist. Shape.fill(constraints:) has always treated that as fatal and returned nil. FillingSurface.add(edge:support:continuity:) returned false and left the builder usable, so build() went on to fit a surface to whatever constraints did make it in. Since every add is @discardableResult, ignoring the signal was the default at the call site:

let f = FillingSurface()
f.add(edge: e1, continuity: .g0)
f.add(edge: rim, support: importedWall, continuity: .g1)   // false, silently dropped
let face = f.build()                                       // succeeded

That face was fitted to e1 alone. It neither passed through nor was bounded by rim, the edge the caller cared most about, and it reported a healthy G0 error (measured 2.8e-05 on the truncated-sphere fixture) while doing so: a plausible wrong answer, not a visible failure. The same geometry through Shape.fill(constraints:) returned nil.

The refusal is now sticky: the builder records it, and build() returns nil however many other constraints succeeded, without attempting the fit at all, so isDone stays false and the face and error accessors keep reporting “not built” rather than describing a surface no caller asked for. The two entry points now answer the same for the same input, which is what the #434 convergence was for. @discardableResult becomes harmless: the refusal is reported whether or not the return value was read.

New: FillingSurface.refusedConstraintCount and FillingSurface.hasRefusedConstraint, which separate “a constraint never made it in” from “the fit was attempted and failed”. Both return nil from build():

guard let face = filling.build() else {
    print(filling.hasRefusedConstraint ? "a constraint was refused" : "the fit failed")
    return
}

Source-compatible, behaviour-breaking. No signature changed, but a caller who was relying on build() succeeding after a refused add now gets nil. To attempt a constraint speculatively and carry on, use add(edge:continuity:), which derives the continuity reference from the edge itself and so has nothing to refuse.

Two doc comments promised this behaviour before anything enforced it: add(edge:support:)’s “used or the constraint fails” and OCCTFillingAddEdgeWithSupport’s “the caller should treat the whole fill as failed”. Both are now true.

Also fixed: the derived operation total in README.md / docs/API_REFERENCE.md was 4,266 against a derived 4,268 before this change, a pre-existing two-entry-point drift unrelated to #482. Scripts/count-operations.py --fix takes it to 4,270, of which two are the properties above.

Unreleased: chore, every build of this package emitted an unhandled-file warning (#440)

Version and date deliberately unset; whoever tags stamps them.

swift build emitted found 1 file(s) which are unhandled; explicitly declare them as resources or exclude from the target on every build of this package.

The file was Tests/OCCTStressTests/Fixtures/unify-crash-mmd-kiha10-body5.brep, the 600 KB mesh-sewn solid backing #348’s null-pcurve regression test. It is read straight from the source tree via #filePath, never through Bundle.module, so it is neither a build input nor a resource to copy. OCCTStressTests now declares exclude: ["Fixtures"], which is the accurate description of what it is. Declaring it as a resource instead would have embedded 600 KB in the test bundle that nothing reads.

Who saw it: builds of this package as the root package, which means our own dev loop and CI, plus anyone who clones OCCTSwift and builds it directly. It did not reach downstream consumers. SwiftPM does not construct test targets for non-root packages, so a consumer’s build never had a target that owned Tests/ to diagnose. Measured on Swift 6.3.3 against a synthetic package: the same stray file warns while its package is the root, stays silent through a path-dependency consumer’s swift build and swift test, and does propagate to that consumer once moved into a source target. #440 claimed the warning reached every downstream consumer; that part of the issue was wrong too, and the fix is worth having for the root-build noise alone.

Correcting #440’s own diagnosis: the issue attributed the warning to Tests/occt_parallel_crash_portable.cpp sitting unclaimed at the Tests/ root. That file is not the cause and never was: no target declares path: "Tests", so SwiftPM does not scan the directory root at all and never diagnosed it. Removing it changes nothing, and the warning names the .brep and only the .brep. The fixture predates the issue by five days, so the misattribution was present from filing.

The move #440 also asked for was still worth doing on its own merit, since a reproducer does not belong in the package’s test tree, but not to the suggested destination. That file is the OCCT#1179 parallel-crash sweep (11 operation groups built around Extrema_ExtElCS and ShapeUpgrade_FaceDivide, committed April 2026, three months before #342, with one boolean group out of eleven), so filing it under Scripts/repro/342-boolean-ops/ as “the same family of work” would have mislabelled it. It now has its own Scripts/repro/occt1179-parallel-crash/, with a README covering what it sweeps, why it is kept now that OCCT#1179 is fixed (our own OCCT#1203, shipped in V8_0_0), and what distinguishes it from its boolean-concurrency siblings: it is the only reproducer here driven by CI, on both Windows and macOS. The two paths in .github/workflows/occt-parallel-crash-test.yml follow it.

No API change, no behaviour change, no kernel change.

v1.17.0 (July 2026): pass 1a of the #377 duplication audit, and two source-breaking changes in a minor release

Read this before upgrading. Two changes in this release break source compatibility, which SEMVER.md reserves for a major bump. The exception is deliberate and recorded there; the major version stays reserved for OCCT 9.0. Nothing else in this release requires a source change, and there is no binary or behavioural change to any API not named below.

Breaking: Surface.drawMesh and Surface.evaluateGrid return SurfaceGrid (#404)

Both previously returned [[SIMD3<Double>]], and they nested in opposite orders: drawMesh was [uIndex][vIndex], evaluateGrid was [vIndex][uIndex]. Nothing at the type level caught a caller mixing them up. They now share one SurfaceGrid type indexed by at(u:v:), so the ambiguity is gone rather than documented.

// Before
let mesh = surface.drawMesh(uCount: 30, vCount: 30)
for row in mesh { for p in row { emit(p) } }
let rows = mesh.count, cols = mesh[0].count

// After
let mesh = surface.drawMesh(uCount: 30, vCount: 30)
for u in 0..<mesh.uCount {
    for v in 0..<mesh.vCount {
        if let p = mesh.at(u: u, v: v) { emit(p) }
    }
}
let rows = mesh.uCount, cols = mesh.vCount

SurfaceGrid exposes at(u:v:), uCount, vCount and isEmpty. It is not a Collection and is not subscriptable, so the break is a compile error at every call site rather than anything silent. If you are migrating evaluateGrid specifically, check your index order: its old shape was [v][u], so a mechanical rewrite that assumes [u][v] transposes the data.

No shim is possible here. Swift does not overload on return type alone, so a deprecated overload with the old return type would be ambiguous at every call site that binds the result to a variable.

Breaking: Curve3D.interpolate(points:startTangent:endTangent:) removed (#400)

The no-tolerance overload shadowed its tolerance-aware sibling: Swift always prefers the exact arity match, so the ordinary three-argument call could never reach the tolerance: parameter, which was pinned at 1e-6 regardless of what a caller asked for.

// Before and after, identical source, and it now compiles against the tolerance-aware overload
let c = Curve3D.interpolate(points: pts, startTangent: t0, endTangent: t1)

// Now reachable for the first time
let c = Curve3D.interpolate(points: pts, startTangent: t0, endTangent: t1, tolerance: 1e-4)

In practice most call sites need no edit: the three-argument spelling still compiles and still defaults to 1e-6. It breaks only where the removed overload was referenced as a value (let f = Curve3D.interpolate(points:startTangent:endTangent:)) or passed as a function argument.

Everything else

Pass 1a of the #377 duplication audit: 27 issues, each a pair of API spellings that turned out not to mean the same thing. Eleven of them change what an existing call returns without any compiler diagnostic, so the per-entry sections below carry a behaviour-change table listing each one as was/now. If you upgrade without reading anything else, read that table.

The individual entries follow: #477 (arc-length integrator), #433/#434 (FillingSurface continuity), #398 (continuity enums), #399-#422 (the audit batch), #443 (first-of-N explorer sites).

Consumers on the opt-in prebuilt bridge (OCCTSWIFT_BRIDGE_PREBUILT=1) must take this release’s OCCTBridge.xcframework.zip: the bridge’s C ABI changed (OCCTSurfaceKnotSplitting gained four parameters, five functions were removed), so a v1.16.1 bridge binary no longer matches this Swift layer. Package.swift’s URL and checksum are bumped accordingly. OCCT.xcframework is unchanged and stays pinned at its v1.15.18 asset: this release carries no kernel patch changes.

v1.17.0 (July 2026), fix: Curve3D arc length was integrated as one quadrature across the whole domain (#477)

Curve3D.length and Curve3D.length(from:to:) measured arc length with CPnts_AbscissaPoint::Length, a single Gauss quadrature of order ≤ 24 spanning the entire parameter domain. That is exact for a line or a circle and wrong for anything with many spans, and nothing signalled the difference: the call returned a plausible number. Every other arc-length call site in the bridge (Curve2D, Edge, WireCurve, the property queries) already used GCPnts_AbscissaPoint::Length, which splits the curve at its GeomAbs_CN interval boundaries and integrates each span separately. These two were the only CPnts_AbscissaPoint call sites left in the bridge, and the reference docs had already described them as GCPnts for some time.

Measured against a densely sampled polyline reference over the same domain, on the pinned kernel:

curve spans GCPnts (now) CPnts (before)
40-pt interpolated BSpline, varying speed 39 2.9e-7 rel. 5.1e-2 rel. (5% of 356 units)
60-pt interpolated helix 59 4.3e-15 rel. 3.9e-6 rel.
5-pt interpolated BSpline 4 6.9e-12 rel. 2.5e-3 rel.
line, circle, arc 1 exact exact

(On the helix and 5-point rows the GCPnts figure sits at or below the reference’s own residual error, so it bounds the remaining error rather than measuring it. The first row’s 2.9e-7 is GCPnts’s genuine per-span quadrature residual on a sharply wiggling curve.)

The error is worst where |C'(u)| varies sharply along the curve, which is the ordinary case for an interpolated toolpath or an imported spline, so a CAM step-over or a sweep spacing derived from a curve’s length was percent-level wrong. The ranged overload had the identical gap, and totalArcLength / arcLength(from:to:) / arcLengthBetween(_:_:) inherit the fix as soon as they route through length (#408).

One behavioural change beyond accuracy, on out-of-domain parameters: GCPnts clamps to the curve’s domain where CPnts extrapolated the polynomial past its knots. On the 356-unit test curve, overshooting both ends by a full domain width used to measure 441,972; it now measures the curve’s own length. A range wholly outside the domain used to measure 865,392; it now measures 0. Nothing else in the failure contract moves: probed against the pinned kernel, both integrators agree on reversed ranges, equal parameters, zero-length curves, periodic curves, and unbounded lines, and neither throws where the other does not.

The three bridge functions that already used the composite integrator (OCCTCurve3DArcLength / OCCTCurve3DLength / OCCTCurve3DArcLengthBetween) stay exported for direct C consumers, but they are no longer reached from Swift: #408 routed totalArcLength, arcLength(from:to:) and arcLengthBetween(_:_:) through length/length(from:to:), so every Swift spelling now lands on OCCTCurve3DGetLength/OCCTCurve3DGetLengthBetween. Nothing in Sources/OCCTSwift references the older three. (Superseded: all three were deleted outright by #506, which also found that OCCTCurve3DLength’s pre-bounded adaptor extrapolated past the knots rather than clamping, so it had not in fact inherited this entry’s fix. See the #506 entry.)

New suite Issue477ArcLengthAccuracyTests (OCCTCurveTests) pins all five Swift spellings against an independently computed reference (a Richardson-extrapolated polyline, not the implementation’s own answer), so it fails on the old integrator rather than ratifying it. 5 of its 8 tests fail against the previous code.

v1.17.0 (July 2026), fix: FillingSurface’s continuity mapping was wrong for both non-default orders, and it converged onto Shape.fill’s implementation (#433, #434)

FillingSurface.add(edge:continuity:)/add(freeEdge:continuity:) hand-mapped the plate constraint order onto GeomAbs_Shape locally instead of using occtFillingContinuityToGeomAbs, the helper #430 introduced for Shape.fill: order 1 requested GeomAbs_C1 (curvature) instead of GeomAbs_G1 (tangency), and order 2 requested GeomAbs_C2 (ordinal 4), which every constraint class rejects, failing the whole build() rather than just that one constraint (#433). add returned true regardless, since BRepOffsetAPI_MakeFilling::Add only appends and never validates the order.

FillingSurface also held its own, separate BRepFill_Filling, the private implementation class BRepOffsetAPI_MakeFilling (what Shape.fill already used) forwards to internally, and never exposes. #434 converges the two onto one implementation: FillingSurface now holds the same BRepOffsetAPI_MakeFilling, built through the same shared occtFillingMakeBuilder, and every add call shares occtFillingAddConstraint outright rather than each having its own copy of the same defensive logic, fixing #433 as a consequence of the convergence rather than as a separate patch. occtFillingAddConstraint is no longer a template now that both callers hold the same concrete filler type.

New: FillingSurface.add(edge:support:continuity:), mirroring FillConstraint’s support-face semantics, a face named here is used or the constraint fails, never silently substituted. Its continuity defaults to .g1, not .g0, matching FillConstraint: at .g0 there is nothing to be tangent or curvature-continuous with, so support is never even read, and a .g0 default would make the “used or fails” guarantee false for the common zero-argument call. Covers the boundary-edge case; FillConstraint.isBoundary also covers free edges with a named support face, which this PR does not add an equivalent for.

// Tangent to the wall the rim came from
let filling = FillingSurface()
filling.add(edge: rim, support: wall, continuity: .g1)

Verified on the same truncated-sphere fixture #430’s own tests use: .g2 on a curved boundary, which previously failed the whole build(), now succeeds and measurably bulges further than .g1: matching Shape.fill’s own curvature-vs-tangency regression test on the other entry point.

v1.17.0 (July 2026), refactor: nine continuity enums collapsed to two shared vocabularies (#398)

OCCTSwift had grown nine separate “continuity level” enums, each written against one bridge call and each re-deriving its own raw-int meaning. Verified against the pinned kernel, they turn out to express exactly three contracts, not one:

contract what OCCT receives enums that expressed it
geometric constraint order, 0/1/2 = G0/G1/G2 a plate constraint order; GeomPlate_CurveConstraint rejects outside [-1, 2] with “The continuity is not G0 G1 or G2” SurfaceContinuity, PlateConstraintOrder, FillingContinuity
required parametric continuity, 0…3 = C0…C3 a GeomAbs_Shape continuity class, or a literal derivative-order integer GeometricContinuity, ApproxContinuity, Shape.BSplineContinuity, Curve3D.ContinuityOrder
a GeomAbs_Shape ordinal reported back nothing; it is a result Surface.Continuity

Collapsed to SurfaceContinuity (.g0 / .g1 / .g2) and a new ParametricContinuity (.c0 … .c3). Surface.Continuity is retained as a result type, and Shape.ContinuityLevel is retained as a strict superset (it adds cn, g1, g2 cases that only dividedByContinuity(criterion:tolerance:) accepts).

No raw value moved, and no bridge code changed, so no existing call’s behaviour changed. Two deliberate widenings, both in Curve3D.continuityBreaks(minContinuity:): .c3 becomes reachable (below), and results past the 256th are no longer silently dropped. The latter is the only new executable code in this PR, and the more consequential of the two for imported geometry, which is where split counts get large. Every retired name and spelling remains as a deprecated alias, so existing source still compiles:

@available(*, deprecated, renamed: "SurfaceContinuity")
public typealias FillingContinuity = SurfaceContinuity      // and PlateConstraintOrder
@available(*, deprecated, renamed: "ParametricContinuity")
public typealias GeometricContinuity = ParametricContinuity // and ApproxContinuity,
                                                            // Shape.BSplineContinuity,
                                                            // Curve3D.ContinuityOrder

SurfaceContinuity.c0 / .c1 / .c2 also survive as deprecated aliases of .g0 / .g1 / .g2. Two source-compatibility caveats, both fixed by adding a default:

  • An exhaustive switch over any of these enums now needs one, because the old spellings are static properties rather than cases.
  • Curve3D.ContinuityOrder also widens from three cases to four, so even a switch written with the correct .c0 / .c1 / .c2 spellings stops being exhaustive.

Two live defects surfaced while verifying the mappings the issue had assumed correct. Both are pre-existing, both are now pinned by tests, and neither is fixed here:

  • Shape.plateSurface(through:orders:) can never accept .g2. A bare point carries no curvature to match, so GeomPlate_PointConstraint throws above order 1 (the header’s “Order is not 0 or -1” doc is itself wrong; 1 is accepted). The throw fails the whole call, so a single .g2 in an otherwise valid order list returns nil.
  • Curve3D.ContinuityOrder’s cap at .c2 made every order it offered a no-op. GeomConvert_BSplineCurveKnotSplitting splits where degree - multiplicity < ContinuityRange, and a cubic interpolation is already C2 at its interior knots. Measured: ranges 0, 1 and 2 all return just the two end knots; range 3 returns five parameters. Sharing ParametricContinuity makes .c3 reachable, which fixes this as a side effect.

Also re-enabled AdvancedPlateSurfaceTests, disabled since v0.23.0 under the claim “Plate surface operations cause segfault in OCCT”. A C++ replica of that exact bridge path shows no segfault at orders 0 or 1, and the suite is 8/8 clean over repeated runs. Same pattern as the #341 re-enablement: the claim was never characterised and does not hold up.

Docs: naming-conventions.md carried GeometricContinuity.c0, .c1, .g1 as its worked example, an enum/case combination that never existed.

v1.17.0 (July 2026) - refactor + fix: the #377 duplication audit, 24 near-duplicate API pairs collapsed onto one implementation each (#399-#422)

One entry for the whole batch, since the 24 issues are one piece of work with one recurring finding: wherever two spellings of “the same” operation existed, they were not actually the same. Each pair was run against the pinned kernel before being unified rather than assumed equivalent, and the divergences that turned up were real: a factory that accepted what its twin rejected, a tolerance an order of magnitude apart, a parameter one spelling could not reach. Collapsing a pair onto one implementation therefore changes behaviour on one side of it, listed in full below.

Sibling entry: #398 (continuity enums), directly above. #433/#434 (FillingSurface) is the same audit and has its own entry.

Behaviour changes

Each of these changes what an existing, unmodified call returns. None produces a compiler diagnostic.

Call Was Now Issue
Curve3D.circleFromCenterNormal(radius: 0), ellipseFromCenterNormal(minorRadius: 0), hyperbolaFromCenterNormal with either radius 0, parabolaFromCenterNormal(focal: 0) a live, degenerate curve (gce_Make* rejects only strictly-negative dimensions) nil, matching the direct circle/ellipse/parabola/hyperbola factories #399
Curve2D.circleFromCenterRadius(center:radius:) at radius exactly 0 a live, zero-radius curve nil, matching circle(center:radius:) and the contract Curve2D-Analysis.md already documented #411
Surface.curvatures(u:v:) its own GeomLProp_SLProps at resolution 1e-6 the shared construction at Precision::Confusion() (1e-7), matching gaussianCurvature(atU:v:)/meanCurvature(atU:v:) #405
Surface.normal(u:v:) accepted any \|D1U × D1V\| > 1e-15 (absolute) the same GeomLProp_SLProps degeneracy test normal(atU:v:) uses, a relative sine tolerance of Precision::Confusion(). A near-parallel-derivative point that used to yield a normal now yields SIMD3(0, 0, 0) #401
Surface.approximated() with no arguments tolerance: 0.01, maxDegree: 10 tolerance: 1e-3, maxDegree: 8, matching Curve3D.approximated/Curve2D.approximated #406
Curve3D.totalArcLength, arcLength(from:to:), arcLengthBetween(_:_:) on failure 0.0, indistinguishable from a genuine zero-length result -1.0 #408
Curve2D.arcLength(from:to:) on failure (e.g. reversed u1 > u2, which its range-checked adaptor rejects) 0.0 -1.0 #409
Point2D.distance(to: Curve2D) with no projection (point past a bounded curve’s ends, or a circle’s centre) -1, passed through as if it were a distance, so distance < tolerance read it as “touching” .infinity, which such a test rejects correctly #413
Curve2D.interpolatePeriodic(points:) with exactly 2 points nil (bridge floor count < 3) a valid out-and-back periodic loop, matching interpolate(through:closed:)’s floor of count < 2, which has always allowed it #412
Curve2D.interpolate(points:startTangent:endTangent:) tolerance pinned at 1e-6 with no parameter path to reach it honours the new tolerance: parameter (default 1e-6, so the bare call is unchanged) #410
BRepGraph.sampleFaceUVGrid unpacked uSamples * vSamples points regardless of how many the bridge wrote unpacks the written count, with gaussianCurvatures/meanCurvatures truncated to match #419

Two lower-level changes with the same character:

  • Curve3D.interpolate(points:startTangent:endTangent:) (the overload without tolerance:) is removed. Swift always prefers the exact-arity match, so the ordinary 3-argument call could never reach the tolerance-aware overload it shadowed. The 3-argument call now resolves to that overload with its default (#400).
  • BRepGraph’s 12 adjacency accessors guard count <= 0 rather than count == 0. The bridge ...Count functions narrow through an unchecked int32_t cast, so a negative count now degrades to [] instead of trapping Array(repeating:count:) (#418).

Routing the three arcLength entry points through length/length(from:to:) also moved them onto that pair’s integrator, which was the less accurate of the two the bridge carried. That is fixed in #477 (entry at the top of this file), so the accuracy the arcLength spellings had before #408 is restored and length/length(from:to:) gain it as well. Note the consequence for the C bridge: OCCTCurve3DArcLength, OCCTCurve3DLength and OCCTCurve3DArcLengthBetween are no longer reached from Swift at all (nothing in Sources/OCCTSwift references them), though they remain exported for direct C consumers. (Superseded: #506 deleted all three. OCCTBridge is a target and not a product, so there were no direct C consumers to export them for.)

Public Swift API

Source-breaking, both MAJOR-triggering under SEMVER.md and tracked on #377 so the obligation survives the squash-merge:

  • Surface.drawMesh(uCount:vCount:) and Surface.evaluateGrid(uParameters:vParameters:) return a new SurfaceGrid instead of [[SIMD3<Double>]]. They previously nested in opposite orders ([u][v] vs [v][u]) with nothing at the type level to catch a caller mixing them up; SurfaceGrid is indexed by .at(u:v:), so the ambiguity is gone rather than documented (#404).
  • Curve3D.interpolate(points:startTangent:endTangent:) removed, as above (#400).

Renamed, with a deprecated shim, so existing source still compiles:

  • Curve2D.approximated(first:last:toleranceU:toleranceV:maxDegree:maxSegments:) is now approximatedInRange(...). It wraps Approx_Curve2d (explicit sub-range, separate U/V tolerances, continuity fixed at C2), a genuinely different algorithm from approximated(tolerance:continuity:maxSegments:maxDegree:)’s Geom2dConvert_ApproxCurve, and nothing steered a caller between them (#407).

Additive:

  • Surface.mirrored(acrossPoint:) and Surface.mirrored(acrossAxis:direction:), closing the gap between Surface’s copy-returning transform family and Curve3D/Curve2D’s (#414).
  • BRepGraph.contains(uid: GraphItemUID), the counterpart to the existing GraphUID/GraphRefUID overloads (#417).
  • Surface.KnotSplitResult.uSplitParams/vSplitParams, and LawFunction.knotSplitParameters(continuityOrder:). Both back onto values their OCCT class already computed and discarded (#403).
  • ArcLengthCurveAdaptor, a public protocol carrying the composition logic (point/tangent(atAbscissa:), points(spacing:)) that EdgeCurve and WireCurve previously duplicated line for line. Both stay distinct public classes (#422).
  • tolerance: parameters on Curve2D.interpolatePeriodic and Curve2D.interpolate(points:startTangent:endTangent:), defaulted to the value each used to hardcode (#410, #412).

Bridge C API

The C surface is not covered by the Swift SemVer promise, but direct OCCTBridge.h consumers are affected:

  • Signature changed: OCCTSurfaceKnotSplitting takes four more arguments (outUParams, maxUParams, outVParams, maxVParams) and reports true counts even when writing was truncated, so a caller can retry with a bigger buffer (#403).
  • Removed: OCCTCurve3DCreateArc3Points (#415), OCCTGceMakeCone and OCCTGceMakeCylinderFrom3Points (#420), OCCTGeom2dLPropCurExt and OCCTGeom2dLPropCurInf along with the OCCTCurInfPoint struct (#402). Each duplicated a sibling that survives.
  • Added: OCCTSurfaceMirrorPoint, OCCTSurfaceMirrorAxis (#414), OCCTBRepGraphHasItemUID (#417), OCCTLawBSplineKnotSplitParams (#403).
  • Behaviour: OCCTCurve2DProjectPoint2D returns NaN for the parameter on failure and documents outDistance < 0 as the signal. It used to return 0.0, which is a legitimate parameter: projecting a segment’s own start point onto it returns exactly 0 at distance 0 (#413). OCCTInterpolate2DPeriodic, OCCTInterpolate2DWithTangents and OCCTInterpolateWithTangents now forward to their canonical siblings rather than holding a second copy, so direct C consumers get the same behaviour as Swift callers (#412, #410, #400).

Unified with no behaviour change

Curve2D.curvatureExtremaDetailed()/inflectionPointsDetailed() now delegate to their plain siblings instead of re-running the same GeomLProp_CurAndInf2d (#402). Curve3D.arc(through:_:_:) became the true alias of arcOfCircle(start:interior:end:) it was already documented as (#415). Curve3D’s six immutable transform functions fold onto the same buildTrsf3D the mutating family used, and the mutating dispatcher gained the IsNull() guard the immutable six already had (#416). BRepGraph’s 12 count-buffer-fetch-map accessors share one helper (#418). Ten flat-buffer unpack sites share unpackSIMD3 (#419). Surface.coneFrom2PointsRadii/cylinderFrom3Points delegate to their GC_Make*-backed counterparts (#420), as do the four overlapping plane factories (#421). The EdgeCurve/WireCurve bridge primitives share one Adaptor3d_Curve& helper set; public C symbol names are unchanged (#422).

Test coverage went up where the audit found none: Curve3D.arc(through:_:_:), Surface.rotated(axisOrigin:axisDirection:angle:), BRepGraph.rootProductIndices, EdgeCurve/WireCurve’s points(spacing:)/parameterRange/point(atParameter:), and degenerate-input cases across every unified factory pair had no dedicated tests before.

v1.16.1 (July 2026): fix, unify consumed the shape it was given, so a declined merge still damaged the caller’s solid (#446)

ShapeUpgrade_UnifySameDomain rewrites sub-shapes of the shape it is handed, and those rewrites reach the TShapes the caller’s Shape still shares. The result: the idiom every consumer writes, take the merge if it is valid, otherwise keep what you had, silently damaged what you had. A solid that was a clean, non-self-intersecting manifold before the call came out of it self-intersecting, with no result ever accepted. OCCT documents the class as producing a new shape and says nothing about the input being consumed.

Root cause, traced in the kernel: TransformPCurves (ShapeUpgrade_UnifySameDomain.cxx:1228 and two sibling sites) writes temporary pcurves onto the input’s edges, against a scratch reference face the algorithm builds for itself, and only ever removes them again if that reference face is later replaced. SetSafeInputMode does not cover this path, it is unguarded, and safe mode is OCCT’s default anyway, so the reporter was already running it. Minimal reproducer: two stacked coaxial cylinders (same-domain cylindrical faces, differently parameterised, which is what drives that path). The input’s serialized BREP grows from 1676 to 1778 bytes across a single unified() call that the caller never even used the result of.

Every unify entry point now works on a private copy (BRepBuilderAPI_Copy), so the caller’s shape is untouched whatever the algorithm does to its own input: Shape.unified(), Shape.simplified(), and UnifySameDomainBuilder. No API change and no new parameter, the copy is unconditional, because “the input survives” is what every call site already assumed.

What the copy costs. A real fraction of the call, not a rounding error: measured here at 0.6 ms against 2.3 ms for the merge itself on an 84-face compound (28%), and review measurement on a 600-face compound put it at 18% where there is real merging to do but 64% on a nothing-to-merge input , which matters because unified() is the standard post-boolean cleanup and often finds nothing. Peak memory doubles for the duration. Unconditional anyway: a copyInput: flag would put the silent-corruption path back within reach of anyone optimising a hot loop, and it can be added later if a caller measures this as a real problem.

And what it costs in identity. The result now shares no sub-shapes with the input, even where nothing was merged, before this change an untouched face came back IsSame with the one it came from. Shape.isSame(as:), isPartner(with:) and isEqual(to:) are public and consumers do map selections and attributes across by sub-shape identity, so that code has to key off geometry instead. This is the unavoidable price of the fix rather than a choice, but it is a behaviour change and is pinned by a test. UnifySameDomainBuilder.keepShape(_:) is unaffected: it still takes the input’s own sub-shapes and maps them across for you.

Deduplication. Three bridge call sites constructed ShapeUpgrade_UnifySameDomain independently (OCCTShapeUnifySameDomain and OCCTShapeSimplify in OCCTBridge_Healing.mm, the builder in OCCTBridge_Modeling.mm), each with its own copy of the construct/Build()/null-check sequence, which is exactly why one fix had to be written three times. They now share occtUnifySameDomain/occtUnifySameDomainInput/occtUnifySameDomainMapped (OCCTBridge_Internal.h). The two public Swift entry points are not redundant and both stay: Shape.unified() is the one-shot, UnifySameDomainBuilder adds tolerances, keepShape and internal-edge control. Their concatBSplines defaults disagree (true vs false), left as-is rather than silently changed under existing callers, but now cross-documented on both.

UnifySameDomainBuilder.keepShape(_:) names a sub-shape of the caller’s shape, so working on a copy means mapping it onto its counterpart there; without that, every keepShape would have quietly kept nothing. setSafeInputMode(_:)’s doc comment, which claimed it “copies input shape to preserve original”, was wrong on both counts and is corrected.

Sibling audit (so nobody files a speculative sweep): the same input-consumption class was checked on the same fixture for ShapeFix_Shape (healed(), fixed(tolerance:)), BRepAlgoAPI_Defeaturing (withoutSmallFaces(minArea:)) and ShapeUpgrade_ShapeDivideClosed (dividedClosedFaces()). All four leave the input byte-identical. ShapeUpgrade_UnifySameDomain is the outlier, not the first of a family.

Bridge-only fix: no OCCT kernel change, no xcframework rebuild, no new operations (count unchanged at 4,258). New regression suite Issue446UnifyInputMutationTests (OCCTShapeHealingTests) asserts the input’s serialized BREP is byte-identical across all three entry points, that a declined merge leaves the shape’s validity/self-intersection/volume unchanged, that the merged result’s geometry is unmoved, that the result no longer shares sub-shapes with the input, and that keepShape still blocks a merge through the copy (per-edge: the junction seam blocks it, a cap circle does not). Full swift test (combined with #397 below): 4480 tests in 1292 suites, all passing.

Also fixed in passing: docs/reference/Shape-Features.md credited withoutSmallFaces(minArea:) to ShapeAnalysis_CheckSmallFace + ShapeUpgrade_UnifySameDomain; OCCTShapeRemoveSmallFaces uses neither, it collects small faces by area and removes them with BRepAlgoAPI_Defeaturing.

v1.16.1 (July 2026): fix, Shape.faceAddHole() rejected every circular hole wire, and never oriented the ones it kept (#397)

Shape.faceAddHole(face:wire:) returned nil for every hole wire built from circular geometry, Wire.circle(origin:normal:radius:) and a hand-joined two-arc circle alike, at any radius, in either winding, while a polygonal hole on the same face worked. The cause was this wrapper’s own degenerate-wire guard (added for #234, which declines a zero-area hole because the invalid face it produces goes on to SIGSEGV ShapeFix downstream): the guard counted the wire’s vertices, and a circle has one (Wire.circle) or two (two joined arcs), so it tripped the “fewer than 3 distinct vertices” rejection meant for out-and-back line segments. Nothing in OCCT was rejecting these wires; they never reached BRepBuilderAPI_MakeFace::Add at all.

The guard now samples points along the wire’s curves rather than at its vertices, which is what lets a circular hole describe the area it encloses. Sampling alone would weaken #234’s protection, an arc traversed out and back spreads its samples over a curve and so clears the collinearity test that catches a straight out-and-back, so the loop’s own vector area is checked as well, and a wire whose mean width (area ÷ longest chord) falls below Precision::Confusion() is still declined.

Fixing the nil exposed a second half to the same defect, pre-existing and equally silent: the wrapper never oriented the wire it added. MakeFace::Add does no reorienting of its own, so a hole wound the same way as the face’s outer boundary was added as a second outer loop, a 20×20 face given a 2×2 hole came back with area 404 rather than 396, and its prism was not a valid solid. Only callers who happened to hand in an opposite-wound wire ever got a hole. faceAddHole now compares the hole’s winding against the face’s outer boundary in the face’s plane and reverses the wire when they match, the same rule OCCTShapeCreateFaceWithHoles has used since #274, with a validity-checked retry of the other orientation for non-planar hosts where no plane can be fitted. Either winding now cuts, and the sampler both tests share is now one helper (occtSampleWirePoints) rather than two copies of the same traversal.

One behaviour change beyond the two bugs: when neither winding yields a BRepCheck-valid face, faceAddHole now returns nil instead of the invalid face. That case is not a winding question, the wire does not lie inside the face’s boundary, and no orientation makes it a hole, and returning a non-nil invalid face is exactly what #234 established breaks callers later. Pre-existing behaviour (the old code never validated its result at all), tightened here because the winding retry introduced the validity check anyway.

Bridge-only fix: no OCCT kernel change, no xcframework rebuild, no new operations (count unchanged at 4,258). New regression suite Issue397CircularHoleTests (OCCTModelingTests) covers Wire.circle and two-arc holes in both windings, the extruded-solid volume, same-winding polygon holes, the zero-area curved wire that must still be declined, and the boundary-crossing wire that no winding can turn into a hole; Issue234DegenerateHoleTests passes unchanged. Full swift test (combined with #446 above): 4480 tests in 1292 suites, all passing.

v1.17.0 (July 2026) - fix: three more first-of-N TopExp_Explorer sites dropped most of their input (#443)

#442’s audit note asked for the first-of-N TopExp_Explorer idiom to be grepped for across the whole bridge before closing it, on the grounds that #439 had two instances and #442 two more, and every one was found by someone reading the neighbouring lines rather than from a report. That sweep found 23 candidate sites: 8 false positives, 5 where “first” is the documented contract, and 10 undocumented silent picks. Three of those were confirmed by measurement to lose most of their input, and are fixed here. The other seven are documented rather than changed: each is singular by contract, and widening it would change what its arguments mean.

Measured against a compound of two disjoint 10 mm boxes (2 solids, 12 faces, 2000 mm³):

call before after
Shape.solid(from:) 1 solid, 1000 mm³ 2 solids, 2000 mm³
Shape.upgraded() 1 solid, 1000 mm³ 2 solids, 2000 mm³
AssemblyNode.setTriangulationFromShape 4 nodes, 2 triangles 48 nodes, 24 triangles

Shape.solid(from:) (and Shape.solidWithFullHistory(from:)) is the sharpest of the three: its own doc names sewing output as the expected input, and sewing two bodies yields exactly the two-shell input it mishandled. After #442 the two sibling entry points disagreed on that same shape: sewn.solidFromShellFixed() gave 2 solids / 2000 mm³ and Shape.solid(from: sewn) gave 1 / 1000. Both now go through #442’s occtBodyBoundingShells, so they agree by construction; the helper moved to OCCTBridge_Internal.h for that. The history variant shares one ShapeBuild_ReShape across the per-body ShapeFix_Solid runs, so the single history still covers every body: BRepTools_ReShape::History() builds a fresh BRepTools_History from the context’s whole replacement map on each call, so earlier bodies’ replacements are still in it (confirmed against occt-src).

Shape.upgraded() is documented as a “sew + make solid + heal pipeline” and is the call most likely to be pointed at a raw imported mesh, where multi-body input is the norm. Its solid step now builds one solid per body-bounding shell. Two limits inherent to sewing first are now documented rather than silent: sewing dissolves the input’s solids, so a hollow body’s cavity is filled (8000 mm³ for a 7000 mm³ hollow cube, unchanged from before but never stated); and the solid step replaces the sewn shape rather than merging into it, so content sewing could not attach to a shell is not carried through. fixed(tolerance:) is the call for either case, since it does not sew.

AssemblyNode.setTriangulationFromShape meshed the whole shape and then stored only the first face’s triangulation on the label. A 6-face box and a 12-face two-box compound both stored 4 nodes and 2 triangles, for a doc comment reading “by meshing a shape”. Arguably the worst of the three, since the attribute is what later readers trust as the label’s geometry. It now merges every meshed face into one Poly_Triangulation in the shape’s own coordinate system: per-face locations applied to the nodes, reversed faces’ winding and node normals flipped so the result is consistently outward (the same rules Shape.mesh() applies), the worst contributing face’s deflection carried over, node normals kept only if every face has them, and per-face UV nodes dropped since they index parameter spaces that stop meaning anything once the faces are pooled.

A latent case in #442’s own helper was found and fixed with them. occtBodyBoundingShells ran its enclosure-parity pass within each solid but added every shell belonging to no solid unconditionally, on the reasoning that a free shell has no declared cavity relationship. But sewing dissolves the solid that carried that declaration, and sewing is the ordinary way all of these calls are reached. Measured on a sewn hollow box: the same two shells answered 1 body while inside a solid and 2 once sewn, the second being the cavity as a positive solid; on {hollow body, body inside its cavity} it gave 3 bodies for a 2-body part. Free shells are now one further group through the same parity pass, so both readings agree. Containment among free shells is geometric rather than declared, so a closed shell alone inside another is read as that one’s cavity: the same reading OCCT’s own solid convention gives it, and the only one available without a declaration. A body nested inside a cavity is enclosed twice, so it is still a body. None of #442’s shipped cases change (two disjoint free shells → 2 solids; the same free shell twice → 1).

The parity pass is O(N²) classifications in the size of one group, which was fine when a group was one solid’s 1-3 shells but is not when it is every free shell of a sewn mesh. A conservative bounding-box pre-filter now prunes pairs before any ray cast, and skips building a classifier for a reference that overlaps nothing: enclosure implies box containment, so no verdict changes. Measured at 200 disjoint shells: 160 ms without it, 0.7 ms with, identical results. This is not the Bnd_Box rule #442 rejected; that failed as the decision rule, which is exactly why it is sound as a pre-filter.

Behaviour change for consumers: Shape.solid(from:), Shape.solidWithFullHistory(from:) and Shape.upgraded() now return a compound where they previously returned one arbitrary body’s solid, for multi-body input only. Single-body input is untouched, down to the returned shape type. A caller that assumed .solid unconditionally should read .solids instead. Shape.solidFromShellFixed() returns one body where it returned two for a sewn hollow part, the second having been the cavity. AssemblyNode.setTriangulationFromShape stores nodes in the shape’s frame rather than the first face’s local frame, so a located shape’s stored coordinates move as well as its node count.

The seven remaining undocumented picks are documented in place, in both the Swift doc comment and the bridge, with why each stays singular: MedialAxis.init(of:) (a medial axis is a property of one face, and the result type holds one graph), Shape.halfSpace(face:referencePoint:) (a half-space is bounded by one face by definition), Shape.fillet2D(vertexIndices:radii:) and chamfer2D(edgePairs:distances:) (the indices are numbered within the chosen face, so covering every face would need per-face index lists and a different signature), Shape.splitByWireOnFace(_:faceIndex:) and locOpeSplit(wiresOnFaces:) (the pair list is already where several wires are named), and Shape.solidFromShells(_:) (the argument order is the outer-versus-cavity contract, so widening each argument would make it stop meaning anything). OCCTShapeBuildThreadCutter is internal and single-body by construction. Shape.faceRestricted(by:) and Wire.offset(by:joinType:) already stated what they do and are untouched.

Review round 3: the one item left open, Shape.solid(from:)/solidWithFullHistory(from:)’s own BRepBuilderAPI_MakeSolid failure path. Flagged unresolved in review round 2: before this PR, a MakeSolid failure on the (only) shell was a hard failure for the whole call; per-body, it silently dropped just that one body from the result compound, the exact defect class this PR exists to fix, reopened one layer down. Checked against occt-src rather than assumed: BRepLib_MakeSolid’s single-shell constructor (BRepLib_MakeSolid.cxx) unconditionally calls Done() after adding the shell, with no closure or coherence check anywhere in the path, matching its own header’s “a solid under construction is always valid.” Confirmed with a probe: BRepBuilderAPI_MakeSolid on a 5-of-6-face open shell, and on a bare empty shell, both come back IsDone() == true with a non-null Solid(): just a geometrically invalid one (BRepCheck_Analyzer.IsValid() == false), not a failure. So the failure this review item worried about cannot occur for this call, confirmed by re-running the two new regression tests below against the pre-fix code: both still pass, because the code path they exercise never reaches the branch in question either way.

Fixed anyway, for defense in depth: the dead continue (drop) is now push_back (keep the shell as-is), matching OCCTShapeSolidFromShell’s identical “keeps a body rather than dropping it if that changes” comment, same belt-and-braces contract as its #442 sibling, zero observable behaviour change today. Two new tests (solid(from:) keeps an open body rather than dropping it, solidWithFullHistory(from:) keeps an open body rather than dropping it) pin the guarantee that actually matters regardless of mechanism: a closed shell alongside a disjoint 5-of-6-face open shell still comes back as 2 bodies / 11 faces, not 1.

Review round 4: OCCTShapeUpgrade had the same dead-but-inconsistent MakeSolid drop round 3 fixed on its siblings, and two doc passages didn’t hold up. Round 3 fixed the silent per-body drop on Shape.solid(from:)/solidWithFullHistory(from:), but OCCTShapeUpgrade’s own per-shell loop, touched by this same PR, still had the plain continue-style drop, missed because it wasn’t one of the two functions round 3’s own finding was about. Fixed the same way: push_back the unfixed shell on IsDone() == false instead of dropping it, same belt-and-braces reasoning, same “dead code today” status (verified: BRepBuilderAPI_MakeSolid’s single-shell constructor never fails). A new test, upgraded() keeps an unclosable shell's faces rather than dropping them, pins it, on face count rather than solid count, since upgraded()’s later ShapeFix_Shape pass reclassifies the kept body so it no longer counts as a TopAbs_SOLID, unlike the round-3 siblings that don’t run that pass.

Also: three existing docs/reference/ pages covering methods documented (not changed) by this PR, Shape-Measurement.md (fillet2D/chamfer2D/solidFromShells), Shape-Builders-1.md (splitByWireOnFace), Shape-Builders-2.md (locOpeSplit(wiresOnFaces:)), had the caveat added to the Swift doc comment but not mirrored into the page; now they match. And upgraded()’s own doc comment claimed “free shells each become a body,” which is what this very PR’s free-shell parity fix made untrue, the next line already stated the correct, narrower rule (an even-enclosed free shell is skipped), so the topic sentence was reworded to match rather than contradict it, reusing solid(from:)’s more precise phrasing.

Bridge-only fix: no OCCT kernel change and no OCCT.xcframework rebuild.

v1.16.0 (July 2026): fix, Shape.fixSolid()/solidFromShellFixed() healed only the first body (#442)

Shape.fixSolid() and Shape.solidFromShellFixed() healed the first solid (respectively the first shell) a TopExp_Explorer yielded and discarded every other body without a signal. The return was a well-formed Shape that looked like a healed version of the input, so nothing downstream could tell that most of the part was gone: a 2000 mm³ two-box compound came back as a 1000 mm³ single solid.

Both now cover every body. ShapeFix_Solid cannot be handed a compound, its constructor and Init take a TopoDS_Solid, and TopoDS::Solid throws on anything else, so multi-body input has to be driven one solid at a time. A compound result is not a new return category: ShapeFix_Solid::Shape() already hands one back when a single solid’s shells resolve into several bodies, so callers that handled fixSolid() correctly for a multiconnex solid already handle this.

solidFromShellFixed() builds one solid per body-bounding shell, decided by enclosure parity: within each solid, a shell bounds a body iff an even number of the other shells enclose it, plus every shell belonging to no solid at all (the usual shape of sewing output). A solid’s cavity shells are skipped: a hole is not a body, and building one as a positive solid would return a compound whose volume double-counts the part (8000 + 1000 for a 7000 mm³ hollow box). Enclosure is decided with BRepClass3d_SolidClassifier, not by shell orientation, measured, both a hollow solid’s outer and cavity shells are FORWARD, so orientation carries no signal here; each reference is read once with PerformInfinitePoint so an inside-out shell flips the sense rather than the answer.

An open shell is skipped in the reference role (BRep_Tool::IsClosed, which for a shell is a real edge-pairing check rather than the Closed() flag, so a genuine cavity shell still qualifies). Open shells reach this code by contract, the same call accepts them and returns them as unclosed solids, and under parity every shell is a reference, so one that cannot enclose anything would still add a spurious ±1 to the others. Measured on {A_outer, A_cavity, openShell wrapping both}: without the guard the outer shell is dropped outright (enclosed count 1, odd) and the cavity emitted as a positive body.

Parity is used because every rule that picks one reference shell and calls everything outside it a body is wrong on some real input, and the two obvious choices fail on different ones. Measured, on one solid holding {A_outer 8000, A_cavity 1000, B_outer 27000}: picking the widest shell emits A_cavity as a positive body (36000 mm³ against a correct 35000), because it is outside B; picking BRepClass3d::OuterShell gets that case right but names the cavity on an inside-out hollow solid, emitting the true outer shell as a second overlapping body (9000 mm³ for a 7000 mm³ part). Parity assumes no single enclosing shell and needs no orientation, and it also reads a body nested inside another body’s cavity correctly, enclosed twice, so even, so a body (8064 mm³, measured). It is O(N²) classifications in the shells of one solid, where N is 1-3 on any real input and 1 is free.

Neither call can drop a body by any path: a solid ShapeFix_Solid fails to heal comes back unhealed rather than vanishing, Shape()’s compound is flattened by direct children so a shell it could not close is kept rather than skipped by a TopAbs_SOLID explorer, and a compound holding the same free shell twice yields one solid, not two.

Reading the result of fixSolid(): because no body is dropped, a result body is not always a solid. ShapeFix_Solid hands back a shell it could not close, and a solid it fails to heal comes back unhealed. result.solids.count can therefore be lower than the number of input bodies with nothing lost. Spot an unclosed body by walking the result’s direct children (child(at:) over nbChildren), not subShapes(ofType: .shell), which maps at every depth and so reports one shell per healthy solid as well, making it useless as a failure signal. A body that came back unhealed is still a solid; use isValid for that.

Behaviour change for consumers: these two calls now return a compound where they previously returned one arbitrary body’s solid, for multi-body input only. Single-body input is untouched, down to the returned shape type. A caller that assumed .solid unconditionally should read .solids instead; a caller that wants one specific body should pick it before healing.

Unlike #439, no doc comment was being violated here, the Swift docs were one-liners that said nothing about multi-body input either way, so this is a design decision rather than a contract fix. Returning nil for multi-body input (what #439 did for outerShell) was rejected: outerShell answers a question about one body and has no meaningful answer for several, whereas refusing to heal a two-body part is a capability loss with no upside. ShapeFix_Shape was checked as the “call the other class” alternative the issue suggested, it does handle a multi-solid compound correctly (2 solids, 2000 mm³) and is already wrapped as Shape.fixed(tolerance:…), now cross-referenced from fixSolid() for callers with mixed content to preserve.

OCCTShapeFixSolid also gains the if (!shape) return nullptr; guard its siblings in the file use. Not reachable through the Swift API, but a null deref is an uncatchable SIGSEGV rather than something the enclosing try would catch.

Documentation correction: solidFromShellFixed() was previously described, before and after this change, as returning nil when a shell does not close. Reading ShapeFix_Solid.cxx, SolidFromShell does B.MakeSolid(solid); B.Add(solid, sh); unconditionally before any classification and returns that even on its exception path, it never returns a null solid and never rejects an open shell. The only nil is “no shells at all”; an open shell comes back as a solid that is not closed.

Bridge-only (no OCCT kernel change, no OCCT.xcframework rebuild). Operation count is unchanged at 4,258, behaviour and documentation only. Source comments, OCCTBridge.h and the generated reference (docs/reference/Document-OCAF-Attributes.md) all state the same rule.

v1.16.0 (July 2026): fix, Shape.outerShell answered for the wrong body on a multi-solid compound (#439)

Shape.outerShell returned the first solid’s shell on a compound holding more than one solid, where its own doc comment specified nil. The result was a plausible-looking Shape that silently answered for one arbitrary body, so callers guarding on nil never fired and every measurement taken against it was wrong with no signal. On the reporter’s 2-solid part a per-vertex sweep went from mean 0.0131 mm / max 0.2511 mm to mean 2.3129 mm / max 18.2483 mm, output that reads as a poorly fitted part, not as an error.

OCCTShapeOuterShell took the first solid a TopExp_Explorer yielded without ever checking whether a second followed. OCCTShapeInnerShells (#212) had the identical defect and is fixed with it: a 2-solid compound reported the first solid’s cavities as though they were the compound’s.

Both now resolve through one occtSoleSolid helper that accepts a solid, or a compound/compsolid wrapping exactly one solid, and returns nothing for a container of two or more. This is the contract the doc comment already stated; it is a behaviour change only for inputs that were being answered incorrectly.

Behaviour change for consumers: a caller that passed a multi-solid compound or compsolid to outerShell and got a shell back now gets nil; the same input to innerShells now gives []. That shell was one arbitrary body’s, so any measurement against it was already wrong. Migrate to outerShells (per body), solids.flatMap(\.innerShells) (per body), or Shape.compound(shape.subShapes(ofType: .face)) (whole boundary, cavities included).

Added Shape.outerShells: [Shape] (OCCTShapeOuterShells), the outer shell of every solid, in exploration order, so the fix is not purely subtractive. Equivalent to solids.compactMap(\.outerShell) in a single traversal. Note these shells drop internal void walls by design; to measure against the complete boundary of a multi-body part, cavities included, use Shape.compound(shape.subShapes(ofType: .face)).

Bridge-only (no OCCT kernel change, no OCCT.xcframework rebuild). Source comment, generated reference (docs/reference/Shape-Measurement.md) and OCCTBridge.h now state the same rule, the generated page had been paraphrasing the contract with the parenthetical dropped.

v1.16.0 (July 2026): fix, Shape.fill SIGSEGV’d on its own default parameters (#430)

FillingParameters defaults continuity to .g1, so the ordinary Shape.fill(boundaries: [wire]) call requested tangent continuity. For any boundary edge borrowed from an existing face, the normal way to get one, that took the whole host process down with an uncatchable SIGSEGV rather than returning nil.

The bridge always used BRepFill_Filling’s face-less Add(edge, order) overload. That overload fetches the edge’s pcurve and its [first, last] range, then builds its constraint from the untrimmed pcurve, discarding the range it just read. For the usual Geom2d_Line pcurve that means a ±2e100 parameter span instead of, say, [0, 2π]. The resulting constraint cannot be projected, and GeomPlate_BuildPlateSurface::Perform’s projection-failure recovery branch then dereferences its own myGeomPlateSurface, which Perform unconditionally nullifies on entry and never assigns on that path. Both defects are upstream and present in OCCT master; neither is reachable through the face-carrying Add(edge, face, order) overload, which trims correctly.

Fixed bridge-side by keeping the face-less overload out of the call path whenever continuity is above positional: a support face is used if one is available, derived from the edge’s own pcurve surface if not, and only a boundary edge with no pcurve at all falls through to the old overload, where OCCT’s documented Standard_Failure makes it a clean nil. Verified equivalent to a kernel-patched build: identical G0/G1 errors and identical geometry.

Two new overloads make the continuity reference explicit rather than implied:

  • Shape.fill(boundaries:supportedBy:parameters:): each boundary edge takes its tangency reference from that edge’s own ancestor face in a given shape. The “cap this opening so it flows into the walls around it” case.
  • Shape.fill(constraints:parameters:) with the new FillConstraint, per-edge support face, continuity order, and whether the edge bounds the face or is an internal constraint.

A face named through FillConstraint.support is now used or the fill fails. It previously fell back to a face derived from the edge when the named one carried no pcurve, which answered with a continuity reference the caller never asked for and gave no signal that their choice had been discarded. Auto-picked faces (supportedBy) still degrade per edge, since nothing was chosen there to begin with. Note a planar face is legitimately usable even with no pcurve stored, because BRep_Tool::CurveOnSurface projects onto a plane on the fly.

Also corrected (#431), at both sites that had it:

  • OCCTShapeFill’s BRepOffsetAPI_MakeFilling constructor call bound maxDegree/maxSegments/continuity to Degree/NbPtsOnCur/TolAng, leaving MaxDeg and MaxSegments at their defaults and making the angular tolerance the continuity ordinal. Measured effect on a cylinder-rim fill: G0Error 0.615 before, 0.00040 after.
  • OCCTFillingCreate (backing FillingSurface) passed maxDegree/maxSegments as SetResolParam’s 3rd and 4th arguments, which are NbIter and Anisotropie, so maxDegree silently became the solver’s iteration count (8 instead of 2, roughly 3x the work at the documented defaults) and maxSegments became a bool. SetApproxParam, the only place MaxDeg and MaxSegments can actually be set, was never called at all, leaving both documented parameters inert. FillingSurface(maxDegree:maxSegments:) now controls what its names say.

Continuity mapping is now explicit and documented: BRepFill_Filling forwards the GeomAbs_Shape value to GeomPlate_CurveConstraint as an integer plate order and rejects anything outside [-1, 2], so .g2 is GeomAbs_C1 (ordinal 2). GeomAbs_G2 (ordinal 3) always throws, despite OCCT’s header docs naming it as the curvature value.

FillingSurface reached the same OCCT defect through its own bridge implementation and crashed identically (#432). The constraint helpers moved to OCCTBridge_Internal.h and both entry points now share them, so that crash is fixed too.

Note on the planar/curved split: worth knowing before probing this family. The same face-less call is a catchable Standard_Failure on a planar support surface, which rejects the ±2e100 parameters, and an uncatchable SIGSEGV on an unbounded or periodic one (cylinder, sphere, cone), which accepts them. The pre-existing filling tests only ever used rectangles and polygons at .c0, so neither half of the defect ever showed.

Was open, now fixed above: FillingSurface’s continuity mapping was wrong in its own way, .c1 requested curvature rather than tangency, and .c2 landed on an order OCCT rejects, which failed the entire build() (add returned true regardless; it only appends). Fixed as #433, folded into #434’s convergence of the two wrappers onto one implementation, see the entry above. The kernel patch for the two upstream defects, and the upstream filing, remain deferred.

v1.15.20 (July 2026): fix, Edge.circleProperties returned nil for every full-circle edge (#378)

Edge.circleProperties (MeasurementHelpers.swift) fits a circle through three points sampled at [parameterBounds.first, mid, parameterBounds.last]. For a full circle the underlying curve is periodic and parameterBounds is (0, 2π), so point(at: parameterBounds.last) evaluates to the same point as point(at: parameterBounds.first) (identical to ~1e-16), the three-point fit then received two coincident points and returned nil for every full-circle edge: a drilled hole, a bore, a plain cylinder’s cap boundary. Partial arcs (first != last) were unaffected.

Fixed: when parameterBounds spans a full 2π (periodic curve), sample the third point at 2/3 of the range instead of at bounds.last, and the second point at 1/3 instead of the midpoint, all three samples land at distinct, non-wrapping parameters. Partial-arc sampling (midpoint + bounds.last) is unchanged. No public API surface change, same signature, same nil-for-non-circular-edges contract, so this is a patch per docs/SEMVER.md.

Tests: edgeCirclePropertiesFullCircle (v0.143 Circle property extraction suite, OCCTCurveTests), a cylinder’s two full-circle cap edges now yield non-nil circleProperties with the correct radius and isFullCircle == true; confirmed it fails against the pre-fix code.

v1.15.19 (July 2026): docs + tests, Shape.mesh()/Shape.loadSTL() winding guarantees, retract the #375 “loses winding” concern (#375)

Not a bug, investigated and retracted, both parts. #375 asked whether Shape.mesh() (always outward for a valid solid, even after a mirror) and Shape.loadSTL() (reportedly “locally inconsistent” after round-tripping a globally-reversed STL) were losing orientation information. Both were root-caused with a ground-truth C++ test against the pinned xcframework, independent of any Swift-side code.

  1. Shape.mesh() outward-normalization is genuine, intentional OCCT behavior. A BRepPrimAPI_MakeBox box already has a mixed FORWARD/REVERSED face-orientation split (3/3) baked into its topology; mirroring it (gp_Trsf::SetMirror, a negative-determinant transform) through BRepBuilderAPI_Transform produces the identical 3/3 split, and both the original and mirrored mesh read 12/12 triangles outward. OCCT compensates a mirror transform by flipping face orientation flags, preserving the invariant that a valid solid’s faces always classify consistently outward, the bridge’s existing face.Orientation() == TopAbs_REVERSED check (already correct) has nothing left to get “wrong”. There is no way, via a valid Shape, to get caller-controlled/”wrong-way” winding, that’s what Mesh(vertices:normals:indices:) is for.

  2. Shape.loadSTL() preserves facet winding exactly, including a full global reversal. A from-scratch, independently-verified box STL, both normally wound and uniformly, globally reversed, round-trips through StlAPI_Reader (BRepBuilderAPI_MakeShapeOnMesh) + BRepMesh_IncrementalMesh + the bridge’s extraction as fully consistent in both cases (12/12 outward, then 12/12 inward; zero shared-edge orientation conflicts either way). The “locally inconsistent” result that prompted the issue traced to a bug in the reporting test’s own STL fixture generator (a quad() helper that copy-pasted the bottom face’s relative vertex layout onto the top face without mirroring it, so the top face’s own “non-reversed” baseline was already backwards), confirmed by reproducing that exact fixture’s geometry and finding the same defect independent of any reversed flag. Not an OCCTSwift bug; not filed upstream.

Docs: Shape.mesh(linearDeflection:angularDeflection:), mesh(parameters:), and loadSTL(from:)/loadSTL(fromPath:) (Sources/OCCTSwift/Shape.swift) each gain a - Note: explaining the orientation guarantee, pointing at Mesh(vertices:normals:indices:) for caller-controlled winding.

Tests: Issue375MeshWindingTests (OCCTMeshTests), a mirrored box still meshes 100% outward, both mesh() overloads. Issue375STLWindingTests (OCCTIOTests), a normally-wound box STL round-trips 100% outward; a globally-reversed box STL round-trips as a clean 100% inward (not a fraction strictly between 0 and 1, which would mean local inconsistency).

Docs + tests only, no code behavior change, no binary change, reuses the v1.15.18 xcframework (the binaryTarget URL is unchanged).

v1.15.18 (July 2026), fix (kernel): Resource_Manager:Debug / Storage_Schema:ICurrentData() races (#374)

The two upstream OCCT foundation-layer races #371’s confirmation harness turned up, filed as OCCT#1398. Moving every document to a private TDocStd_Application (#371) made application/schema construction itself concurrent for the first time, something the old shared singleton never allowed, and that surfaced two previously-uncaught races.

  1. Resource_Manager::Resource_Manager(const char*, bool) writes a file-scope static bool Debug on every construction with zero synchronization; every fresh app’s first DefineFormat() call lazily constructs its own Resource_Manager, racing another thread’s concurrent first construction.
  2. Storage_Schema::ICurrentData() is a function-local static Handle mutated with no lock: Write() sets it for one store’s duration, and any Storage_Schema construction, including the throwaway one PCDM_ReadWriter_1 builds on every Open(), nulls it out from under a concurrent in-flight save or load.

Fix: Resource_Manager::Debug → std::atomic<bool>. Storage_Schema gets a new ICurrentDataMutex() (recursive, since Write() re-enters BindType()/AddPersistent()/ PersistentToAdd() on the same thread via driver callbacks) guarding every touch point: constructor, Write()’s whole body, BindType(), TypeBinding(), AddPersistent(), PersistentToAdd(), HasTypeBinding(), ISetCurrentData(). No public API changes; bridge untouched, only the pinned OCCT.xcframework kernel binary changed (Scripts/patches/0016).

Confirmed via a dedicated TSan reproducer (the “unguarded” variant of #371’s own confirmation harness): 13 races + SIGABRT before the fix, 0/4 clean runs after (8×30, 8×50, 10×60, 8×40). Full Scripts/tsan-stress.sh run gate (10 scenarios) clean, 0 regressions on any prior scenario. Full swift test clean. Filed upstream as OCCT#1398 (repro, filed during #371); this fix is proposed as the corresponding kernel PR. See Scripts/repro/374-resource-manager-storage-schema-race/ for the full writeup.

v1.15.17 (July 2026), fix (bridge): stop using the XCAFApp_Application:GetApplication() singleton (#371)

Prompted by upstream maintainer feedback on OCCT#1396 (our #353 repro issue): XCAFApp_Application::GetApplication() “exists solely for compatibility reasons”; OCCT’s own guidance since 7.1 is a private TDocStd_Application per caller, not a shared singleton. Our whole #341/#344/#349/#353 race cluster traced back to every document sharing that one singleton.

Fix: OCCTDocument’s constructor (OCCTBridge_Internal.h) and every other bridge call site that grabbed the singleton (9 total, across OCCTBridge_Document.mm/OCCTBridge_IO.mm) now build a private new TDocStd_Application() instead, confirmed behaviorally equivalent via a ground-truth C++ test before touching bridge code. CDF_Application::myDirectory/myReaders/ myWriters and CDM_Application::myMetaDataLookUpTable (the state #344/#349/#353 fixed) are all per-instance fields, so a private app per document makes that state exclusive to one document by construction. Two latent bugs fixed along the way: OCCTDocumentLoadOCAF/OCCTDocumentLoadGLTF each opened a document through a different app instance than the one stored on the returned OCCTDocument: harmless only because both were the same shared singleton before this change.

Not a clean win, a dedicated confirmation harness found two new upstream races. Testing the new pattern in isolation (private app per thread, zero shared state, zero serialization, run against the real TSan-instrumented kernel) surfaced Resource_Manager::Resource_Manager() (unsynchronized global Debug) and Storage_Schema::ICurrentData() (unsynchronized global Handle), both previously uncaught because every prior TSan investigation shared one application instance, which accidentally serialized them down to “runs once, ever.” Filed upstream as OCCT#1398, not yet fixed in the kernel. ocafStoreMutex() (the #349 bridge mitigation) is not redundant after this refactor, its coverage was expanded (not removed) to also wrap OCCTDocumentDefineFormatBin/ BinL/Xml/XmlL/BinXCAF/XmlXCAF and OCCTDocumentCreateWithFormat, previously outside the lock.

Upstream kernel PRs for #344/#349/#353 were NOT withdrawn: they fix real bugs in the singleton pattern OCCT’s own header still calls “the only valid method”; every other OCCT consumer following that guidance remains exposed. This change only reduces our own bridge’s exposure to those specific mechanisms.

Full swift test (4428 tests) clean. Scripts/tsan-stress.sh swift (bridge-level, 445 tests) clean. Scripts/tsan-stress.sh run (kernel-level gate, 9 scenarios including the new 371-getapplication-singleton-elimination) clean. See docs/thread-safety.md and Scripts/repro/371-getapplication-singleton-elimination/ for the full writeup.

Binary release: OCCTBridge.xcframework (the opt-in prebuilt bridge from #339) changed, so Package.swift’s URL/checksum are bumped to this release. OCCT.xcframework is unchanged (still v1.15.15), this is a bridge-only change, no kernel patch.

v1.15.16 (July 2026), fix (bridge): Shape.fuseAll(_:) internal parallelism caused data corruption under concurrent calls (#367)

Found continuing #342’s classification pass. OCCTShapeFuseMulti (backs Shape.fuseAll(_:)) was the only bridge call site that set builder.SetRunParallel(true), internal OCCT parallelism for a single call. Under concurrent load this was actively unsafe, not just an oversubscription concern as #342 originally framed it: two threads’ top-level Build() calls, each requesting internal parallelism, submit work to the same process-wide OSD_ThreadPool::DefaultPool(), and worker threads from one caller’s dispatch can end up processing another caller’s data.

Evidence (Scripts/repro/342-boolean-ops/occt_342_boolean_stress.cpp, fuse_multi_parallel scenario): 8 threads × 50 iterations, 400/400 concurrent operations produced wrong results, 27 faces instead of the correct 13 (volume matched almost exactly, consistent with duplicated/torn geometry rather than floating-point imprecision), plus 237 ThreadSanitizer race reports across foundational topology code (TopoDS_Builder::Add, TopExp_Explorer, BRep_Tool::Range, BOPTools_AlgoTools::MakeSplitEdge). By contrast, the plain (non-parallel) boolean ops, Shape.union(with:)/.subtracting(_:)/.intersecting(_:), none of which ever set SetRunParallel, are clean: 2000 concurrent mixed operations, 0 errors, 0 wrong results, 0 races.

Fix: dropped SetRunParallel(true) entirely, Shape.fuseAll(_:) now runs on OCCT’s safe serial default. Removes the trigger rather than locking around a known-corrupting path. New regression suite Issue367FuseMultiThreadSafetyTests. Full swift test (4428 tests) clean.

Not fixed here: the underlying mechanism looks like a genuine OSD_ThreadPool/ BOPTools_Parallel concurrency bug in OCCT’s own shared-pool dispatch, more foundational than anything else found in this project’s TSan series (#298/#341/#344/#349/#353/#361 were all specific static/global variables in narrower classes). Root-causing it properly is tracked as a follow-up investigation in #367, out of scope for this release.

Binary release: OCCTBridge.xcframework (the opt-in prebuilt bridge from #339) changed, so Package.swift’s URL/checksum are bumped to this release. OCCT.xcframework is unchanged (still v1.15.15), this is a bridge-only fix, no kernel patch.

v1.15.15 (July 2026), fix (kernel): #341’s AutoNamingScope revised to a per-instance override after upstream review (#363)

Follow-up to #341 (v1.15.5) and its Swift-side analogue #363/#365 (v1.15.14, TNaming_Scope moved to a per-Document field). Upstream reviewer gkv311 caught something our own v1.15.5 writeup got wrong: XCAFDoc_ShapeTool::AutoNamingScope’s recursive_mutex serialized the three known override call sites (RWMesh_CafReader::fillDocument(), RWGltf_CafReader::fillDocument(), XCAFDoc_Editor::Expand()) against each other, but every other read of theAutoNaming in XCAFDoc_ShapeTool.cxx (AddShape, MakeReference, SetSHUO) stayed outside any scope, an unrelated, unscoped caller on another thread could still observe another thread’s temporary override. Making the flag std::atomic<bool> closed the memory-safety gap, not the logical one; our own “the flag is deliberately global” framing at the time was the mistake.

Fix: theAutoNaming was never meant to express per-document intent, the three overriding call sites each want to suppress naming for their own document’s build, and XCAFDoc_ShapeTool is already one instance per document, so the override belongs there. XCAFDoc_ShapeTool::OwnAutoNamingScope replaces AutoNamingScope: a per-instance myOwnAutonaming field (-1 inherits the process-wide default, 0/1 is a local override), with OwnAutoNaming()/SetOwnAutoNaming()/UnsetOwnAutoNaming() accessors. No locking needed at all, independent documents never touch each other’s state. XCAFDoc_Editor::Expand()’s self-recursion (the reason the old fix needed a recursive mutex) still composes correctly: OwnAutoNamingScope saves and restores whatever override state the instance had on entry, not an unconditional reset, so nesting on the same instance works the same way the old recursive lock did, just without a lock. theAutoNaming itself stays std::atomic<bool>; SetAutoNaming()/AutoNaming() remain callable concurrently from any thread at any time.

Verified: same TSan stress as the original fix (10 threads × 200 iterations, obj_roundtrip_unique), zero races, matching the prior result. New isolation scenario (Scripts/repro/363-own-autonaming/occt_363_isolation.cpp) directly checks the property the mutex fix couldn’t guarantee: half the threads locally override via OwnAutoNamingScope on their own document while the other half do plain unscoped AddShape() on independent documents relying on the process-wide default, concurrently, 3000 operations, zero leaks. Patch 0011 updated in place (same fix, corrected design, not a new patch number). Full production OCCT.xcframework rebuild (macOS, iOS device, iOS simulator); full swift test clean.

Upstream: OCCT#1388 updated to the new design and re-reviewed. CI green across all 3 platforms, every build/GTest/regression/test job.

v1.15.14 (July 2026), fix (bridge): naming scope moved to a per-Document field instead of a shared instance + mutex (#363)

Follow-up to #361, prompted by upstream reviewer feedback on #341’s analogous fix (OCCT#1388 review comment: “a mutex is not the right tool here… usage remains unprotected”). v1.15.13’s docNamingScopeMutex() made concurrent access to the shared TNaming_Scope instance memory-safe, but left the underlying design bug in place: every Document still shared the same TNaming_Scope, so one document’s valid-label set could leak into another’s regardless of locking, a correctness bug, not just a race, that predates #361’s fix.

Fix: TNaming_Scope moved from a shared process-wide static to a field on OCCTDocument itself (doc->namingScope, OCCTBridge_Internal.h). No lock needed at all, two threads working on two different Document instances no longer touch anything shared. docNamingScopeMutex() was removed entirely; the six OCCTDocumentNamingScope* bridge functions now read/write doc->namingScope directly (two of the six, OCCTDocumentNamingScopeClear/ValidCount, gained a null-check on doc they’d never had, a symptom of the same bug, since the old implementation ignored the doc parameter entirely and touched the shared global instead).

New test namingScopesAreIsolatedAcrossDocuments in Issue361SharedSingletonThreadSafetyTests directly verifies the correctness property (two documents’ valid-label sets and counts stay independent), a deterministic, single-threaded assertion, not a race-dependent exerciser. Full swift test (4427 tests) clean, both source and OCCTSWIFT_BRIDGE_PREBUILT=1 build paths.

Font_FontMgr’s font-list cache (fontListMutex(), also from #361) is unaffected, that mutex stays, since the system font registry is genuinely one process-wide resource by OCCT’s own design, unlike TNaming_Scope. See docs/thread-safety.md’s updated section for the general lesson this draws: a mutex is the right tool only when state is meant to be shared; when it was wrongly made global in the first place, the fix is relocating ownership, not locking access to the wrong owner.

Binary release: OCCTBridge.xcframework (the opt-in prebuilt bridge from #339) changed again, so Package.swift’s URL/checksum are bumped to this release. OCCT.xcframework is unchanged (still v1.15.11).

Filed as a companion to #363, which also tracks applying the same per-instance-override redesign to #341’s upstream AutoNamingScope PR, that part is deliberately deferred: prototype + test locally first, then respond to the OCCT#1388 review and update that PR, not the other way around.

v1.15.13 (July 2026), fix (bridge): two more unsynchronized process-global singletons, TNaming_Scope shared instance, Font_FontMgr font-list cache (#361)

Found continuing the #342 (bridge-level thread-handling contract) scoping pass that produced #359, an earlier survey flagged two needs-investigation spots as high-confidence pattern matches for the #341/#344/#353 shape; verified both directly this release before fixing.

  • getDocNamingScope() (OCCTBridge_Document.mm) returns one process-wide TNaming_Scope instance shared across every OCCTDocument. Construction is safe (C++11 magic statics), but TNaming_Scope’s own NCollection_Map<TDF_Label> myValid has no internal synchronization, two threads calling namingScopeValid/IsValid/ValidChildren/Unvalid/ClearValid/ ValidCount on two unrelated documents race on that shared map.
  • Font_FontMgr’s font-list cache (OCCTBridge_Visualization.mm): g_fontList/ g_fontListPopulated is a classic unsynchronized check-then-act lazy-init, and the public OCCTFontMgrInitDatabase() can reassign both at any time from any thread, racing an in-progress iteration in any of the read-side functions.

Fix: bridge-only, matching the established #341/#344/#353 pattern, a dedicated std::mutex per shared resource (docNamingScopeMutex(), fontListMutex()), held for the duration of every access. No OCCT kernel change needed since both races are in bridge-owned static state, not inside OCCT’s own classes. New regression suite Issue361SharedSingletonThreadSafetyTests (Tests/OCCTThreadTests/), a basic exerciser, not the authoritative verification, same honesty caveat as #341/#359’s equivalent suites. Full swift test (4426 tests) clean, both source and OCCTSWIFT_BRIDGE_PREBUILT=1 build paths.

Binary release: OCCTBridge.xcframework (the opt-in prebuilt bridge from #339) changed again, so Package.swift’s URL/checksum are bumped to this release. OCCT.xcframework is unchanged (still v1.15.11, no kernel patch this release).

v1.15.12 (July 2026), fix (bridge): STEP import + 3 later-added STEP writers missing the DE mutex, #181-B’s fix didn’t fully hold (#359)

Found while scoping #342 (bridge-level thread-handling contract). #181-B (fixed by PR #184) found that STEPControl/STEPCAFControl/IGESControl readers and writers share OCCT’s process-global Interface_Static parameter table, and serialized every STEP/IGES writer entry point on a shared igesMutex(): the closing comment claimed this “serializes all of them.” Auditing every function in OCCTBridge_IO.mm/OCCTBridge_Document.mm that constructs a STEPControl_Reader/Writer or STEPCAFControl_Reader/Writer, or calls Interface_Static::Set* directly, found that claim didn’t hold: 18 functions were missing igesMutex(), every STEP import function (all added after PR #184, across the “v0.58.0 STEP Full Coverage” and “v0.168.0 Progress” batches; the original #181-B report was specifically about concurrent writes, so import was never in scope), plus 3 STEP export functions added after PR #184 shipped (OCCTExportSTEPWithName, OCCTExportSTEPWithModeProgress, OCCTDocumentWriteSTEPWithModes).

Fix: added igesMutex() to all 18 sites, matching the existing #181-B convention. Bridge-only, no kernel change, no OCCT.xcframework rebuild. New regression suite Issue359STEPThreadSafetyTests (Tests/OCCTThreadTests/) exercises concurrent STEP import/export through the Swift API, like #341’s equivalent suite, this is a basic exerciser (confirms no deadlock and no round-trip regression), not the authoritative verification; a missing-lock bug on a non-recursive std::mutex doesn’t reliably manifest as an observable Swift-level failure at modest concurrency. Full swift test (4424 tests) clean, both source and OCCTSWIFT_BRIDGE_PREBUILT=1 build paths.

Not the same issue as #280 (constructing a STEPCAFControl_Reader poisons subsequent STEP writes), confirmed during triage that #280 is a different, already-fixed mechanism (not Interface_Static- related, resolved via a kernel patch in v1.10.1).

Binary release: OCCTBridge.xcframework (the opt-in prebuilt bridge from #339) changed, so Package.swift’s URL/checksum are bumped to this release; consumers building with OCCTSWIFT_BRIDGE_PREBUILT=1 need the new release asset. OCCT.xcframework is unchanged (still v1.15.11, no kernel patch this release).

v1.15.11 (July 2026), fix (kernel): CDM_Application:myMetaDataLookUpTable + CDM_MetaData field races under concurrent document save/close (#353)

Surfaced while validating the #349 fix: post-#349 TSan runs consistently produced one different, previously-masked race, the “fixing one race exposes the next” pattern from #341→#344→#349 continuing. CDM_Application::myMetaDataLookUpTable is shared process-wide (one CDM_Application singleton, since #344) with zero synchronization: CDM_MetaData::LookUp()’s map mutation, CDM_Document::SetMetaData()’s whole-table iteration on every save, and each CDM_MetaData’s own myIsRetrieved/myDocument fields all race independently. TSan confirmed the exact trace from the issue: SetMetaData() reading IsRetrieved() racing a different document’s destructor tearing down its own metadata entry on another thread, 1 confirmed race + SIGABRT (exit 134) on stock #349-fixed kernel.

Fix: CDM_Application gets a mutable std::mutex guarding the lookup table, threaded through CDM_MetaData::LookUp() and CDM_Document::SetMetaData()’s iteration; CDM_MetaData gets its own private mutex guarding myIsRetrieved/myDocument, independent of the table lock. TSan: 1 race + SIGABRT → 0 races, clean exit, across 5 runs. swift test --filter OCAFSaveLoadBinaryTests/ OCCTXCAFTests and 3× full swift test (4423 tests) all clean. CDM_MetaData::myDocumentVersion has the identical unguarded-field shape but on the reference-resolution path, not TSan-observed, flagged as a plausible sibling, not fixed here. See Scripts/repro/353-cdm-metadata-lookup-table/ for the reproducer and full writeup. Filed upstream as Open-Cascade-SAS/OCCT#1396 (repro) / OCCT#1397 (fix, CI green on all platforms).

v1.15.10 (July 2026): ThreadSanitizer gate for concurrency-touching changes (docs/tooling)

Docs-and-tooling release; no API, bridge, or kernel changes, and no new binary assets (the OCCT.xcframework.zip binary target continued to resolve from the v1.15.9 release until v1.15.11 above).

Formalizes the TSan protocol that found and validated #298/#341/#344/#349 as a routine gate (#355, plus the #356 sysroot fix):

  • Scripts/tsan-stress.sh: build produces a minimal-module ThreadSanitizer OCCT (all carried patches applied) into Libraries/occt-install-tsan; run compiles the Scripts/repro/ stress harnesses and executes a 7-scenario gate matrix that must be race-clean; swift runs swift test --sanitize=thread on the concurrency-focused suites (wrapper-only coverage).
  • Scripts/tsan.supp: curated suppressions; only confirmed-benign races or filed-and-open kernel findings, each with an issue link and a removal condition. The #353 entry was removed in v1.15.11 once that kernel patch landed.
  • docs/thread-safety.md: new “ThreadSanitizer gate” section defining when the gate is required (new concurrent bridge paths, newly parallel-wrapped subsystems, mutex removals, new thread-safety kernel patches) and the rule that new concurrent usage patterns add a scenario.
  • Verified end-to-end: gate green (7/7 scenarios, zero unsuppressed races) against the patched V8_0_0_p1 kernel.

Context: upstream OCCT CI runs no sanitizers, so races this gate does not catch are caught by nobody. See the ecosystem report docs/occt-kernel-bug-deep-dive-2026-07.md (SecondMouseAU/ecosystem#23).

v1.15.9 (July 2026), fix (kernel): PCDM_StorageDriver/PCDM_Reader driver-instance reentrancy SIGSEGV under concurrent Save/SaveAs of the same format (#349)

CDF_Application::WriterFromFormat/ReaderFromFormat cache one storage/retrieval driver instance per document format and hand the same cached instance back to every subsequent Store()/ Retrieve() call for that format, including from different threads, different documents, concurrently. Found while validating the #344 fix. PCDM_StorageDriver/PCDM_Reader subclasses (BinLDrivers_DocumentStorageDriver et al.) are not reentrant: Write()/Read() mutate instance-level scratch state (myRelocTable, myTypesMap, and others) with no synchronization, so two threads calling Write() on the same cached instance corrupt it, a reliably reproducible SIGSEGV (BinMDF_ADriverTable::AssignIds on a torn myTypesMap), confirmed by TSan (136 race warnings + crash on stock kernel). Structural, not BinLDrivers-specific, XmlLDrivers, BinXCAFDrivers/XmlXCAFDrivers, and TObj drivers all share the same base classes and pattern.

Fix: PCDM_StorageDriver/PCDM_Reader each get a mutable std::mutex guarding their own Write()/Read(), held at the three call sites (CDF_StoreList::Store, CDF_Application::Retrieve, CDF_Application::Read) that invoke a cached, possibly-shared driver , every format driver subclass inherits the guard for free. TSan: 136 races + SIGSEGV → 0 races, clean exit. The interim bridge-side mitigation (ocafStoreMutex(), shipped v1.15.6) stays in place, same PR1→PR2 pattern as #298/#341/#344. See Scripts/repro/349-ocaf-driver-reentrancy/ for the reproducer and full writeup. Filed upstream as Open-Cascade-SAS/OCCT#1393 (repro) / OCCT#1394 (fix, CI green on all platforms).

A separate, previously-masked race surfaced during validation of this fix (CDM_Application::myMetaDataLookUpTable, unsynchronized), out of scope for #349, filed as #353.

v1.15.8 (July 2026), fix (kernel): ShapeUpgrade_UnifySameDomain unguarded null-pcurve dereference SIGSEGV on mesh-sewn solids (#348)

UnifySameDomainBuilder.build() SIGSEGV’d (Address 0, uncatchable in-process) on a real mesh-sewn solid, found via OCCTReconstruct#194, minimized to a standalone, deterministic OCCTSwift-only reproducer (just load a BREP, run the builder). Root cause: ShapeUpgrade_UnifySameDomain::IntUnifyFaces (and its file-local SplitWire helper) disambiguate between multiple candidate next-edges at a branching vertex by comparing each candidate’s pcurve tangent direction on the current reference face; three call sites in IntUnifyFaces and a structurally identical pair in SplitWire fetch that pcurve via BRep_Tool::CurveOnSurface(...) and dereference it immediately (->D1(...)/->Value(...)) with no IsNull() check, unlike every other CurveOnSurface call site in the same file, which do check. CurveOnSurface legitimately returns a null handle when an edge has no pcurve on the given face, routine for a raw mesh-sewn solid (BRepBuilderAPI_Sewing from an STL/mesh import) at a vertex shared by more than two edges. Confirmed via a debug (-g -O0) single-TU override-link + lldb bt: resolves precisely to ShapeUpgrade_UnifySameDomain.cxx:4003 (aPCurve->D1(...)), reached via IntUnifyFaces → UnifyFaces → Build. Fixed (kernel patch Scripts/patches/0013-*, xcframework rebuilt): all five sites guard with IsNull(), following the file’s own established pattern, a missing pcurve on a candidate edge means “skip it, not a rankable direction”; a missing pcurve on the current edge falls back to treating all candidates as equally likely, same as the existing single-candidate shortcut. New regression test Tests/OCCTStressTests/StressNullInvalidTests.swift’s unifySameDomainOnMeshSewnSolidWithMissingPCurve. Reproducer at Scripts/repro/348-unify-null-pcurve; filed upstream as OCCT#1391 (repro) / OCCT#1392 (fix). #348.

v1.15.7 (July 2026), fix (bridge): 49 unguarded gp_Dir/Geom_Direction constructions, the likely #345 SIGABRT (#345)

#345’s companion crash to #344, root-caused via an audit rather than direct reproduction: #345 was filed with essentially no evidence (exited with unexpected signal code 6, no test name, no backtrace). OCCT’s gp_Dir and Geom_Direction constructors throw Standard_ConstructionError for a zero-length (or near-zero) direction/normal vector. 49 public bridge functions across 7 files constructed these directly from caller-supplied doubles (or called a D0/D1/D2 derivative evaluator, or a GeomEval_*Surface constructor, same degenerate-input throw risk) with no try/catch anywhere in the call chain, e.g. OCCTSurfaceD1/ OCCTSurfaceD2 had none, immediately next to OCCTSurfaceGetNormal, which already did. An uncaught C++ exception crossing the bridge boundary into Swift-generated call frames is a guaranteed std::terminate() → abort() (SIGABRT), leaving almost no diagnostic trail, matching #345’s profile exactly.

Fix: wrapped all 49 functions in try { ... } catch (...) { <safe fallback> }, matching each file’s existing idiom. The 3 functions returning a _Nonnull pointer (OCCTAxis1PlacementCreate/OCCTAxis2PlacementCreate/OCCTOBBCreate) fall back to a valid default axis rather than nullptr, since returning null from a _Nonnull contract would just relocate the crash. Two confirmed false positives left untouched: computePlaneForPoints and buildTrsf3D (two separately-defined static helpers) are both already protected by a try in their sole caller.

Validation: 70 additional full-suite swift test runs (4419-4422 tests each, ~309,540 individual test executions), zero crashes of any kind. New regression tests (Tests/OCCTStressTests/StressNullInvalidTests.swift): mirrorAxisZeroDirection, mirrorPlaneZeroNormal, geomDirectionZeroVector.

Bridge-only fix, no OCCT kernel change, no OCCT.xcframework rebuild (the prebuilt OCCTBridge.xcframework opt-in artifact is rebuilt). Not an OCCT bug, so nothing filed upstream. #345’s own bar for confident closure was “100+ runs with no recurrence”, 70 clean runs plus a fix matching the exact crash mechanism is short of that literal bar but the strongest evidence gathered to date. #345.

v1.15.6 (July 2026), fix (kernel): XCAFApp_Application:GetApplication/CDF_Directory races, the SIGSEGV #341 didn’t explain (#344)

The uncatchable SIGSEGV that survived the #341 fix. #341 (v1.15.5) fixed a real XCAFDoc_ShapeTool::theAutoNaming race, but flagged a separate empirical SIGSEGV (garbage fault address, right after two concurrent OBJ imports) as unconfirmed, filed as #344. Re-running the parallel swift test stress loop 12× against v1.15.5 hit it again once: confirmed genuinely independent of #341’s fix.

Root cause: two races in code the #341 TSan stress never reached. That harness builds TDocStd_Document directly (new TDocStd_Document("BinXCAF")), bypassing XCAFApp_Application/CDF_Application entirely, but every real bridge call (OCCTDocumentLoadOBJ and every other document-producing function) goes through XCAFApp_Application::GetApplication()->NewDocument(...).

  1. XCAFApp_Application::GetApplication()’s lazy singleton init is a textbook double-checked-locking-without-locking bug, two threads’ first concurrent call can both construct a new instance and race to assign the shared handle. TSan shows this is the dominant defect: it produces multiple concurrently-constructed XCAFApp_Application instances, cascading into races across dozens of unrelated destructors as the “losing” instances are torn down mid-flight.
  2. CDF_Directory::Add/Remove/Contains mutate/read myDocuments (a plain NCollection_List) with zero synchronization, every CDF_Application is normally one process-wide instance shared by every caller, so its one CDF_Directory races on NCollection_BaseList::PAppend from every document-creating call on every thread.

Fix, Scripts/patches/0012-CDF_Directory-XCAFApp_Application-thread-safety-344.patch: GetApplication() folds construction into the static local’s initializer (C++11 magic statics, thread-safe exactly once, replacing the separate IsNull()-guarded assignment); CDF_Directory gets a private std::mutex guarding Add/Remove/Contains/Length/IsEmpty/Last.

Validation: a debug (-O0 -g) build with a temporary SIGSEGV/SIGBUS signal handler (backtrace_symbols_fd) crashes ~50% of runs at 10 threads × 3000 barrier-synchronized rounds on stock p1, both captured backtraces resolving to TDocStd_Application::NewDocument -> CDF_Application::Open. TSan (same minimal-module protocol as #298/#319/#341) goes from 234 race reports to 9, all directly in CDF_Directory::Add/PAppend and all showing the same mutex held on both sides of the reported conflict, consistent with a TSan/allocator-recycling artifact rather than a genuine unaddressed race (a control program with a trivially-correct mutex pattern shows no such warning under identical flags). The entire GetApplication()-driven destructor cascade, dozens of unique signatures pre-fix, is gone entirely. New regression test parallelDocumentCreate (OCCTStressTests, StressConcurrentDocumentCreationTests) exercises Document.create() from 40 concurrent tasks.

Found during validation of the fix above: correctly making GetApplication() a true singleton means every caller now genuinely shares ONE TDocStd_Application instance, surfacing more races on that instance’s other unsynchronized state, previously masked by threads sometimes getting different (uncontended) instances. Repeated swift test runs hit a SIGTRAP in Resource_Manager::SetResource (via TDocStd_Application::DefineFormat, called by the common Document.defineAllFormats() test-setup path) and a SIGSEGV in TDocStd_Application:: ReadingFormats iterating CDF_Application::myReaders concurrently with a writer. TDocStd_Application::Resources() has the identical lazy-init bug as GetApplication(); Resource_Manager’s maps and CDF_Application::myReaders/myWriters have zero synchronization. Also fixed in the same patch: a mutex for Resources()’s lazy-init, a std::recursive_mutex for Resource_Manager’s accessors (with an explicit copy constructor, the new mutex broke ShapeProcess_Context.cxx’s existing new Resource_Manager(*sRC) thread-safety workaround, whose own comment already acknowledged this exact defect), and a mutex for myReaders/myWriters. 0/12 further swift test runs of OCCTXCAFTests reproduce either crash after the fix.

A third, architecturally different crash surfaced in the same validation (BinLDrivers_DocumentStorageDriver::Write corrupting a shared, cached, non-reentrant storage-driver instance under concurrent Save/SaveAs of the same format), a shared worker object, not a container needing a lock, so the kernel fix needs its own dedicated investigation; filed separately as #349. It was severe enough alone (~60% crash rate in OCCTXCAFTests once the two races above stopped masking it) that this release also ships an interim bridge-side mitigation: ocafStoreMutex() (OCCTBridge_Document.mm) serializes OCCTDocumentSaveOCAF/OCCTDocumentSaveOCAFInPlace/OCCTDocumentLoadOCAF, the same #298/#341 bridge-mutex-now/kernel-fix-later pattern. 0/12 further swift test runs of OCCTXCAFTests crash after this mitigation.

Reproducer at Scripts/repro/344-cdf-directory/; filed upstream as Open-Cascade-SAS/OCCT#1389 (repro) / OCCT#1390 (fix, two commits). #344.

v1.15.5 (July 2026), fix (kernel): XCAFDoc_ShapeTool:theAutoNaming race, replacing v1.15.4’s bridge mitigation (#341)

Follow-up to v1.15.4. That release shipped an immediate bridge-side mitigation (meshCafMutex()) for the XCAFDoc_ShapeTool::theAutoNaming race characterized in #341. This release carries the real kernel fix and removes the bridge lock as redundant, the #298 PR1→PR2 pattern.

The full hazard, on closer inspection, was bigger than v1.15.4’s writeup captured. Auditing every internal caller of theAutoNaming turned up two more independent save/modify/restore sites beyond RWMesh_CafReader::fillDocument(): a separate, near-duplicate override in RWGltf_CafReader::fillDocument() (not a call into the base class’s version, glTF import has its own copy of the same unsynchronized dance), and XCAFDoc_Editor::Expand(), which additionally recurses into itself while the dance is in flight. Verifying the bridge mutex fix under TSan (with the mutex removed, to test the kernel in isolation) also surfaced a second, narrower problem the v1.15.4 characterization missed: even with the three save/restore sites serialized against each other, an unscoped XCAFDoc_ShapeTool::AddShape call (e.g. any export building a document from an existing shape, outside all three sites) still reads the raw bool with no synchronization at all, a genuine data race independent of the “logical” interleaving bug.

Fix, both layers, in Scripts/patches/0011-XCAFDoc_ShapeTool-AutoNamingScope-341.patch:

  1. XCAFDoc_ShapeTool::AutoNamingScope: a new RAII helper backed by a std::recursive_mutex held for its entire lifetime (not just around the individual get/set calls), so overlapping save/modify/restore sequences from any of the three sites serialize correctly instead of interleaving (recursive because Expand() reenters it on the same thread). All three sites now use it; Expand()’s two duplicate manual-restore-before-return call sites collapse into one destructor-driven restore that fires on every exit path.
  2. theAutoNaming itself is now std::atomic<bool> instead of a plain bool, so every access anywhere in the file, including AddShape’s internal read, is well-defined, closing the residual gap the mutex alone doesn’t reach. Not a semantic change: SetAutoNaming/AutoNaming remain a single global setting, exactly as documented; an unscoped reader still sees “whatever mode is currently active,” it just now gets a real, non-torn value instead of undefined behavior.

Verification. The same TSan stress (10 threads × 200 concurrent OBJ round-trips, each its own file) reports zero theAutoNaming races across 4 separate runs, down from 9-17/run before the fix, verified with the bridge-side meshCafMutex() mitigation removed, testing the kernel fix in isolation. Zero regression on the create_fillet_boolean (#298) and independent-meshing scenarios. RWGltf_CafReader’s copy of the fix compiles cleanly and is mechanically identical to the RWMesh_CafReader path that was exercised, but wasn’t run under TSan directly, this repo’s minimal-module TSan build excludes TKDEGLTF (needs RapidJSON, disabled for build speed).

Binary release: both OCCT.xcframework (kernel patch, all 3 core slices rebuilt) and OCCTBridge.xcframework (the opt-in prebuilt bridge from #339; meshCafMutex() removed) changed, so Package.swift picks up new URLs + checksums for both.

Filed upstream as Open-Cascade-SAS/OCCT#1387 (repro, filed alongside v1.15.4) / OCCT#1388 (fix, draft PR, CLA-covered fork).

v1.15.4 (July 2026), fix: concurrent OBJ/glTF/PLY import races on an unsynchronized OCCT global; the long-claimed “NCollection race” doesn’t hold up (#341)

Background. CLAUDE.md’s Known OCCT Bugs and this changelog have carried a “pre-existing non-deterministic NCollection arm64 race under parallel execution” claim since ~v0.51.0, backing a swift test --no-parallel recommendation and three permanently-.disabled() suites in Tests/OCCTStressTests/StressConcurrencyTests.swift. The claim was never reproduced, root-caused, or filed anywhere, it had been riding purely on observed flakes. Filed and investigated as #341 (companion #342), from an OCCTReconstruct test-contention audit that found the same doctrine costing real CI time downstream (OCCTReconstruct#175/#309).

Investigation. Applied the #298 TSan protocol: a minimal-module ThreadSanitizer build of V8_0_0_p1 (+ all 10 carried patches) covering FoundationClasses+ModelingData+ ModelingAlgorithms+DataExchange. Concurrent create/fuse/fillet and independent meshing scenarios are clean except the already-known, benign BOPAlgo_InitMessages lazy-init race (see the #298 entry below). No NCollection race reproduced at any tested scale. Re-enabled the three long-disabled stress suites, 25/25 clean runs across repeated iterations, and removed their unevidenced .disabled() claims permanently.

What was actually found. A concurrent OBJ round-trip scenario (each thread its own uniquely-named file, so not a file-path collision) reported 9-17 ThreadSanitizer races per run, all resolving to one root cause: RWMesh_CafReader::fillDocument() (the shared base of RWObj_CafReader and RWGltf_CafReader: reachable via OBJ and glTF import, and PLY export via AddShape) saves/mutates/restores XCAFDoc_ShapeTool::theAutoNaming, a process-global static bool, with zero synchronization; XCAFDoc_ShapeTool::AddShape reads the same flag. Same failure class as #298 (an unsynchronized save/modify/restore dance on shared global state), but cosmetic (wrong auto-naming) rather than geometric. Minimal C++ reproducer, methodology, and full writeup: Scripts/repro/341-meshcaf/.

Fix. Bridge-only mitigation (matches the #298 PR1 pattern, no kernel patch or OCCT.xcframework rebuild needed for this release): every OBJ/glTF/PLY CAF-reader/writer bridge function now serializes on a dedicated meshCafMutex() (OCCTBridge_IO.mm). Not yet filed upstream. New regression suite Issue341MeshCafThreadSafetyTests (OCCTThreadTests) exercises concurrent OBJ round-trips through the Swift API, documented honestly as a basic exerciser, not a reliable reproducer at this scale (the race needs sanitizer instrumentation or a much larger operation count to surface without one).

Binary release: OCCTBridge.xcframework (the opt-in prebuilt bridge from #339) changed, so Package.swift picks up the new URL + checksum; OCCT.xcframework is unchanged. Consumers building OCCTBridge from source (the default) get the fix by pulling this tag; consumers on OCCTSWIFT_BRIDGE_PREBUILT=1 need the new release asset.

Still open, filed separately. Two hard crashes (SIGSEGV/SIGABRT, garbage-looking fault addresses) were observed empirically in ~2 of 20 full-suite parallel swift test runs during this investigation. Filed as #344 (SIGSEGV, right after two concurrent OBJ imports, possibly the same theAutoNaming race in a rarer timing window that produces heap corruption instead of just wrong naming, unconfirmed) and #345 (SIGABRT, essentially no localizing evidence). Correction: this entry originally attributed the SIGABRT to a BinTools/TopTools “File was not written with this version of the topology” message seen nearby in the log, and floated fixed-temp-file-path collisions as a working theory. Both were wrong, that message is routine, expected output from two intentional negative tests (Tests/OCCTIOTests/OCCTIOTests.swift’s BREPStringSerializationTests, exercising Shape.fromBREPString on malformed input) and appears in every run including clean ones; it has no connection to either crash, and the fixed-temp-file-path theory was speculation based on that false premise. #342 (bridge-level thread-handling contract: per-call safety classification, scoped/controllable internal parallelism) remains open and gets a concrete first classified entry from this investigation. OBJ/glTF/PLY CAF operations are exclusive (need meshCafMutex()).

v1.15.3 (July 2026), chore: opt-in prebuilt OCCTBridge.xcframework, skip compiling the 62K-line Obj-C++ bridge per consumer rebuild (#339)

Problem, from an OCCTReconstruct build-time audit (OCCTReconstruct#309): OCCTBridge is 16 Objective-C++ files / ~62K lines, each including a large slice of OCCT’s ~1,700 headers. SwiftPM recompiles all 16 from source on every consumer of OCCTSwift, measured at 51.6s wall / 186.5s CPU per rebuild in one path-dependency consumer worktree, on top of the ecosystem’s shared-xcframework setup. A cold artifact re-extraction compounds this by re-stamping header mtimes and invalidating every consumer’s clang module cache.

Fix. Scripts/build-occtbridge.sh compiles the bridge once per platform slice (same core slices as OCCT.xcframework: macOS, iOS device, iOS simulator) and packages it as OCCTBridge.xcframework , compiled objects + public header, no OCCT source involved. Set OCCTSWIFT_BRIDGE_PREBUILT=1 to have Package.swift link this prebuilt binary (local copy if present, else the matching release asset) instead of compiling Sources/OCCTBridge/src/*.mm from source.

Default is unchanged (source build). Every release edits the bridge source directly and tests against those edits (see CLAUDE.md’s Release Process); a prebuilt binary that silently doesn’t reflect fresh edits would be a correctness trap. The prebuilt path is strictly opt-in, full details, including the local escape hatch for bridge iteration and visionOS/tvOS (not covered by the core prebuilt slices), in docs/guides/prebuilt-bridge.md.

Verification. Both paths built clean and the full test suite passed against each: the default source build (regression check, unchanged behavior) and OCCTSWIFT_BRIDGE_PREBUILT=1 (55/55 tests in OCCTThreadTests exercising real boolean/fillet/mesh operations through the prebuilt binary, confirming it’s not just a link-success check).

Binary release: OCCTBridge.xcframework.zip ships as a new release asset alongside the existing OCCT.xcframework.zip; Package.swift’s occtBridgeTarget URL + checksum point at it.

v1.15.2 (July 2026): docs + tests, chaining *WithFullHistory ops across a BRepGraph, retract the #336 “absorbs zero records” report (#336)

Not a bug, investigated and retracted. #336 reported that a second *WithFullHistory boolean op chained onto a prior op’s live output absorbed zero history records into add(_:absorbing:inputRoots: operationName:). Verified two independent ways: probing the raw ShapeHistoryRef directly against the first op’s output faces (bypassing the graph and CollectHistoryInputs/Absorb entirely) showed the same zero records, and out1.volume == out2.volume confirmed the second cut changed nothing geometrically. Root cause: the reporter’s tool placement, not the absorb path. Shape.box(width:height:depth:) is centered at the origin (documented on the API itself), not corner-anchored like raw OCCT’s BRepPrimAPI_MakeBox(w,h,d). The repro’s first “corner” tool landed fully inside the box (an interior-cavity cut) and its second “opposite corner” tool landed entirely outside the box’s actual bounds, the two shapes’ bounding boxes don’t even overlap, so the second cut was a genuine geometric no-op. Zero absorbed records was the correct answer.

Real gap found and closed: test coverage. No existing test chained two *WithFullHistory ops end-to-end (second op fed from the first op’s live, Compound-wrapped output) or passed a non-root NodeRef as inputRoots, every GraphHistoryAbsorbTests case only did a single hop rooted at the graph’s own top-level node. New Issue336ChainedHistoryTests (OCCTBRepGraphTests) covers both: a genuine two-hop chain (opposite real corners) absorbing records at each hop, and a permanent regression guard for the reporter’s exact non-intersecting geometry asserting the zero-record result stays correct.

  • Docs: docs/reference/BRepGraph-Detail-History.md gains a “Chaining multiple operations” section with a runnable multi-hop snippet and the box-centering gotcha, right where add(_:absorbing:...) is documented.
  • Docs only, no code or binary change, reuses the v1.15.1 xcframework (the binaryTarget URL is unchanged).

v1.15.1 (July 2026), fix: isSelfIntersecting(hardTimeout:) can now actually interrupt a stuck self-interference search (#319)

Root cause, two compounding defects in BOPAlgo_ArgumentAnalyzer’s self-interference phase (BOPAlgo_CheckerSI::CheckFaceSelfIntersection → IntTools_FaceFace::Perform → Intf_Interference::Insert), found while independently verifying a reproducer contributed against OCCTReconstruct#295: a pathological artifact ran 619s+ of CPU against a 30s hardTimeout: deadline and never returned.

  1. Intf_Interference::Insert compares points between the new tangent zone and every existing zone via Intf_TangentZone::GetPoint(Index), called inside a doubly-nested loop. GetPoint indexes the zone’s backing NCollection_Sequence, a linked list with no O(1) random access, so each call walks from the nearest end. Profiling (independently reproduced) attributed ~80% of leaf samples to NCollection_BaseSequence::Find. The artifact produces an unboundedly growing number of distinct tangent zones, not one giant merging zone, so this alone doesn’t bound wall-clock time, it just makes the per-comparison cost O(1) instead of O(n).
  2. The self-interference phase never polled its cooperative progress indicator anywhere inside a single face’s check, only between whole-face checks, which is not where the artifact gets stuck.

Fix, both layers. Intf_TangentZone::Points() builds and caches a true NCollection_Array1 per zone in one linear pass on first use (invalidated by any mutation); Insert() indexes through it instead of calling GetPoint in the nested loop. Intf_Interference::SetBreaker (thread-local, RAII-scoped via Intf_InterferenceBreakerScope) lets Insert() poll a Message_ProgressScope every 256 calls and abort by throwing Standard_Failure, unwinding the IntTools_FaceFace/Intf_Interference call stack safely; BOPAlgo_CheckerSI’s self-intersect functor wires this up around IntTools_FaceFace::Perform, gated on !myRunParallel, an exception from an OSD_Parallel::For worker thread would risk std::terminate(), so the checkpoint is only active single-threaded. Kernel patch carried as Scripts/patches/0010-Intf_Interference-O1-tangent-zone-checkpoint-breaker-319.patch, xcframework rebuilt.

Verification. On the linked artifact, a 0.5s deadline now returns in 0.547s and a 30s deadline in 30.1s (vs. 619s+ CPU / never returning on stock p1), correct HasFaulty() results at every deadline tested (0.5s/1s/2s/3s/5s/30s), clean across a 10x repeated-run stress test. Zero regression on clean, overlapping, and grid self-intersection sanity cases (byte-identical output). An empty-zone edge case in Points() is guarded explicitly (NCollection_Array1::Resize(1, 0, false) throws Standard_RangeError for an empty range), caught by a dedicated GTest before it could reach a real caller. New upstream GTests Intf_TangentZone_Test.cxx/Intf_Interference_Test.cxx pass on Linux/Windows/macOS in OCCT’s own CI. Reproducer committed at Scripts/repro/319-selfintersection. Upstreamed as Open-Cascade-SAS/OCCT#1385 (repro) / OCCT#1386 (fix, full CI green on the first submission across clang-format, ASCII check, all 3 platform builds, and GTest); the carried patch retires once it ships in the pinned kernel. Binary release, the xcframework changed, so Package.swift picks up the new URL + checksum; remote SPM consumers get the rebuilt binary.

  • Docs: CLAUDE.md’s Known OCCT Bugs entry added for #319; Scripts/patches/README.md and okf/references/carried-occt-patches.md document patch 0010.

v1.15.0 (July 2026), TopologyGraph renamed to BRepGraph (closes #333)

MINOR, additive; old name still works. TopologyGraph read as too close to OCCT’s own TopoDS_* family (TopoDS_Shape, TopoDS_Face, …) on a skim, without signaling that it specifically wraps the BRepGraph durable-identity engine. Renamed to BRepGraph, matching both the C++ package it wraps and this file’s own name (BRepGraph.swift).

@available(*, deprecated, renamed: "BRepGraph")
public typealias TopologyGraph = BRepGraph

Existing code compiles unchanged (with a deprecation warning) under the old name. New code should use BRepGraph. The typealias stays until a later release drops it per the usual deprecation policy, no removal date set yet.

Docs move alongside the rename: docs/reference/TopologyGraph*.md → BRepGraph*.md, docs/guides/cookbook/topology-graph*.md → brep-graph*.md. The Tests/OCCTTopologyGraphTests target is renamed to Tests/OCCTBRepGraphTests (internal only, no consumer-visible effect).

v1.14.0 (July 2026), feat: *WithFullHistory parity for translate/rotate/scale/mirror/patterns (#331)

Extends the #290 ShapeHistoryRef/add(_:absorbing:) pattern, already shipped for booleans, fillet/ chamfer/shell/defeature (#165), and sew/quilt/heal (#327, v1.13.0), to the last gap: transforms and patterns. Consumers doing incremental persistent-identity tracking (OCCTMCP #91/#93) previously had to fall back to a generation reset after any of these ops, losing continuity for GraphUIDs minted before the transform.

New, all returning (result: Shape, history: ShapeHistoryRef):

  • Shape.translatedWithFullHistory(by:)
  • Shape.rotatedWithFullHistory(axis:angle:)
  • Shape.scaledWithFullHistory(by:)
  • Shape.mirroredWithFullHistory(planeNormal:planeOrigin:)
  • Shape.linearPatternWithFullHistory(direction:spacing:count:)
  • Shape.circularPatternWithFullHistory(axisPoint:axisDirection:count:angle:)
let hole = Shape.cylinder(radius: 3, height: 10)!
let (row, history) = hole.linearPatternWithFullHistory(direction: SIMD3(20, 0, 0), spacing: 20, count: 5)!
let copies = history.record(of: someHoleFace).modified   // 5 corresponding instance faces
graph.add(row, absorbing: history, inputRoots: [root], operationName: "linearPattern")

Implementation, two different shapes, unlike the #327 batch:

  • translate/rotate/scale/mirror all bottom out in BRepBuilderAPI_Transform, which (unlike sewing/healing) genuinely derives from BRepBuilderAPI_MakeShape, so these reuse the existing OCCTBooleanHistoryAsBRepToolsHistory retained-builder/args synthesis path unchanged, the same one fillet/chamfer/defeature use. The plain (non-history) transform functions already construct BRepBuilderAPI_Transform with theCopyGeom = true, which forces BRepBuilderAPI_Transform::Perform down its myUseModif = true branch unconditionally (confirmed in BRepBuilderAPI_Transform.cxx), so Modified()/Generated() always come from the real BRepTools_Modifier, never the “same TShape, just relocated” short-circuit that would otherwise report nothing.
  • Patterns are N:1, not 1:1, so the single-builder synthesis path doesn’t apply: each pattern instance is an independent BRepBuilderAPI_Transform run against the same source shape. History is built manually, one shared BRepTools_History, with every instance’s Modified/Generated results for each original source sub-shape folded in via AddModified/AddGenerated (confirmed these append rather than replace, in BRepTools_History.hxx), so a source sub-shape’s history record reports all count corresponding instance sub-shapes, one per copy including the identity-transformed original at index 0.

New suite TransformPatternFullHistoryTests (OCCTModelingTests), 10 tests, including two graph-absorb integration tests (one 1:1 transform, one N:1 pattern) proving both history shapes flow through OCCTBRepGraphAddWithHistory correctly, and two zero-length-direction regression tests for the exception-safety fix caught in review (gp_Vec::Normalize/gp_Dir’s constructor throw on a zero vector; the pattern wrappers now guard that inside their own try/catch instead of leaving it to the caller). No kernel change, no xcframework rebuild, reuses the v1.12.9 binary.

v1.13.1 (July 2026), feat: hard-bounded isSelfIntersecting, TSan-verified (#319)

Follow-up to #293 (closed, doc-only fix): isSelfIntersecting(timeout:) is cooperative, it can only return once OCCT polls, and BOPAlgo_ArgumentAnalyzer’s self-interference phase has at least one long checkpoint-free stretch (Intf_Interference::Insert). #319 tracked two tracks; only Track 1 ships here.

New: Shape.isSelfIntersecting(hardTimeout:): a genuinely hard wall-clock bound. Runs the check on a detached background thread against a deepCopy() (independent geometry, the standard pattern for concurrent OCCT work), and waits on the calling thread with a real DispatchSemaphore deadline. If the deadline passes first, returns nil immediately and the background computation is abandoned, not cancelled, it keeps running orphaned until it eventually completes (burned CPU traded for a caller-side guarantee, the same trade the #286 mesher-hang caller made). Additive: timeout: is unchanged, and the two overload labels (timeout: / hardTimeout:) disambiguate cleanly.

Prerequisite work, not skipped: the reason this wasn’t done alongside the v1.12.5 doc fix was an open question, is BOPAlgo_ArgumentAnalyzer safe to run on a worker thread concurrently with unrelated OCCT calls on other threads? That shape of concurrency (not OCCT’s own internal SetRunParallel, and not this project’s usual “independent shapes on independent threads” pattern either, since the caller keeps running) had no precedent in this codebase. Investigated with the same method that found #298’s fillet race: a minimal OCCT build (FoundationClasses + ModelingData + ModelingAlgorithms only) with -fsanitize=thread, then a stress harness, 60 bursts × 8 threads, half running self-intersection checks on independent self-intersecting compounds (36 overlapping boxes, genuine interference so Intf_Interference::Insert does real work), half running unrelated fuse+mesh work concurrently on independent shapes. 480 operations, zero TSan race reports, zero wrong-but-plausible results. That’s a positive signal on one stress shape and one access pattern, not an exhaustive audit, the doc comment says so explicitly, and isSelfIntersecting(timeout:) stays the default recommendation unless a caller genuinely needs the hard guarantee.

Track 2 (upstream OCCT report: missing checkpoints + the Intf_Interference::Insert quadratic) remains blocked, still needs the minimal, un-thrashed reproducer this issue originally hoped would fall out of a quiet-host OCCTReconstruct #208 re-run. That hasn’t happened: #208 itself is closed (2026-07-18, no linked commit, superseded by a re-scoped successor line, not resolved), and neither it nor its successors (#252, #254, the currently-open #292) touch self-intersection or timeouts at all. No reproducer exists anywhere in that repo as of this release. No action taken on Track 2.

New suite Issue319HardBoundedSelfIntersection (OCCTModelingTests), 3 tests. No kernel change, no xcframework rebuild, reuses the v1.12.9 binary.

v1.13.0 (July 2026), feat: *WithFullHistory for sewing, quilting, and healing (#327)

add(_:absorbing:inputRoots:operationName:) (#290) solved “an operation rebuilt the shape, keep my selection” for booleans and Tier 2 modification ops, but only when the operation hands back a ShapeHistoryRef, and the operations at the heart of a mesh-to-B-Rep pipeline (sew → heal → solid) returned a bare Shape? with nothing to absorb.

New, all returning (result: Shape, history: ShapeHistoryRef):

  • Shape.sewWithFullHistory(shapes:tolerance:), .sewnWithFullHistory(with:tolerance:), .sewnWithFullHistory(tolerance:) (self-sew)
  • Shape.quiltWithFullHistory(_:)
  • Shape.healedWithFullHistory()
  • Shape.solidWithFullHistory(from:)
let (shell, history) = Shape.sewWithFullHistory(shapes: faces, tolerance: 1e-6)!
let record = history.record(of: someInputFace)   // .modified / .generated / .isDeleted
graph.add(shell, absorbing: history, inputRoots: [root], operationName: "sew")

Implementation: none of these algorithms derive from BRepBuilderAPI_MakeShape, so the existing OCCTBooleanHistoryAsBRepToolsHistory template-synthesis path (built for booleans/fillet/chamfer/ thick-solid) doesn’t apply directly. OCCTBooleanHistory (the opaque handle behind ShapeHistoryRef) now optionally carries an already-built Handle(BRepTools_History) instead of a retained builder:

  • Sewing (sew/sewn both directions), BRepBuilderAPI_Sewing always allocates its own BRepTools_ReShape context (confirmed in occt-src) and records every vertex/edge merge and small-face removal into it via Replace()/Remove() during Perform(), so GetContext()->History() is complete and native, no manual walk needed.
  • Healing (healed), ShapeFix_Shape::Init auto-creates its ShapeBuild_ReShape context, so Context()->History() is likewise safe and complete without an explicit SetContext() call.
  • Solid from shell (solid(from:)), the one case that’s the mirror image: BRepBuilderAPI_MakeSolid genuinely fits the template-synthesis path, but wrapping an already-closed shell into a solid doesn’t modify any sub-shape, so that path would report nothing. The real history source is the ShapeFix_Solid orientation-fix pass, and unlike ShapeFix_Shape, ShapeFix_Solid::Init does not auto-create a context (verified in occt-src), so the bridge now calls SetContext(new ShapeBuild_ReShape) explicitly before Perform().
  • Quilting: BRepTools_Quilt has no ReShape context and no Modified/Generated/IsDeleted, only single-shape IsCopied()/Copy(), so this is the one manual per-subshape walk in the group.

Faithfulness question answered: the issue asked whether sewing’s many-to-one merges (two coincident input edges becoming one output edge, the normal case for sewing, not an edge case) are represented cleanly. Confirmed by reading BRepBuilderAPI_Sewing’s vertex-merge code directly, and by a regression test: both merged inputs are recorded as Modified into the same output edge, neither side is silently dropped or marked Removed. Shape.isSame(as:) verifies the two records’ outputs are the identical edge.

Not implemented: Mesh.toShapeWithFullHistory. The issue’s own open question floated this as a possible answer, and it’s the right one: Mesh.toShape builds every face from scratch out of raw vertex/index arrays, there is no input TopoDS_Shape for ShapeHistoryRef.record(of:) to be called with in the first place, so a *WithFullHistory variant would be a hollow stub that always returns empty records. Identity for a mesh-to-B-Rep pipeline has to be established after the mesh-to-shape step, not carried through it.

New suite SewQuiltHealFullHistoryTests (OCCTModelingTests), 9 tests. No kernel change, no xcframework rebuild, reuses the v1.12.9 binary.

v1.12.10 (July 2026): docs, BREP graph durable identity and UIDs cookbook

Docs only, no code change. Reuses the v1.12.9 binary (the binaryTarget URL is unchanged). Adds a new cookbook, docs/guides/cookbook/topology-graph-uids.md, covering how UIDs are managed in the BREP graph: the three durable-ID flavours (GraphUID / GraphRefUID / GraphItemUID), minting and resolving, the one-graph-instance scope rule and instanceID / graphID provenance (#295), what preserves identity (copy / translated / compact) versus mints a new one (copyFace / rebuild), the deprecated always-1 generation counter, persistence, and how UIDs relate to history absorb (#290). Cross-linked with the existing Topology Graph cookbook.

v1.12.9 (July 2026), carry three more upstream OCCT crash/hang fixes (#323)

Not a bug we hit, a proactive audit. Unlike #310/#317/#318, these three weren’t discovered via an OCCTSwift crash: #323 audited every OCCT PR merged or opened since our V8_0_0_p1 baseline and identified crash/hang fixes in code paths OCCTSwift exercises, per the upstream-fixes-first policy. A fourth candidate from the same audit, OCCT#1380 (ShapeFix_Face::FixPeriodicDegenerated), turned out to already be covered, it’s our own patch 0005, shipped for #317.

Scripts/patches/0007 backports open (third-party) OCCT#1331, fixing OCCT#1330: ShapeAnalysis_FreeBounds::connectWiresToWiresImpl (the same helper 0004 patches for #310) left a stale lwire index when a skipped-loop candidate wire turned out to have zero edges, e.g. a wire wrapping a single internal-orientation edge, so the outer loop’s lwire == -1 termination check never fired and it read invalid memory. Validated by translating the upstream TCL test to C++: a closed triangle wire plus one internal-orientation edge SIGSEGVs 100% of the time on stock p1 + patches 0001–0006, returns a valid wire after the patch.

Scripts/patches/0008 backports merged OCCT#1329, fixing OCCT#1288 (“Boolean operation ‘section’ hangs-up for a pair of cylindrical shapes”): Geom_BSplineCurve::PeriodicNormalization used an O(N) while-loop to bring an out-of-range parameter into a periodic curve’s range, a genuine infinite loop once the parameter’s magnitude vastly exceeds the period (Parameter -= Period becomes a floating-point no-op). Rewritten to O(1). Validated: PeriodicNormalization(1e17) on a normal periodic curve (period ≈ 6.12) hangs indefinitely on stock p1 (wall-clock timeout) and returns instantly after the patch; a 9-case sanity sweep of in-range/near-boundary/several-periods-off values is byte-identical before and after.

Scripts/patches/0009 backports open (maintainer) OCCT#1318: StepData_StepWriter::AddString looped forever writing a single unbroken raw string longer than the 72-character line buffer, no amount of flushing ever made room for text that can’t fit in a full, empty line either. Fixed by splitting the token across as many lines as needed. Validated: a 200-character unbroken name via the public StartEntity/SendString path hangs indefinitely on stock p1 and returns instantly after the patch, correctly split across continuation lines with the text intact; normal-length fields are byte-identical before and after. New regression test STEPWriterOversizedNameTests (OCCTIOTests), reachable directly from Shape.writeSTEP(to:name:) with a >72-char name.

All three (and the existing 0001–0006) verified via the fast override-link technique (patched .o linked ahead of libOCCT-macos.a, no full rebuild needed for validation) before committing to the xcframework rebuild. Two of the three are open, third-party or maintainer PRs, pinned to a specific commit SHA in each patch’s header; re-verify if the PR changes in review before the next repin. Binary release, the xcframework changed, so Package.swift picks up the new URL + checksum.

  • Docs: CLAUDE.md’s Known OCCT Bugs entry added for 0007–0009; Scripts/patches/README.md and okf/references/carried-occt-patches.md document all three.

v1.12.8 (July 2026), fix: Shape.analyze(tolerance:) no longer crashes on a degenerate curve-on-surface edge (#318)

Root cause. BRepGProp_EdgeTool::IntegrationOrder, invoked from BRepGProp::LinearProperties, which backs Shape.analyze(tolerance:)’s small-edge scan, reads an edge’s pole count to pick a numeric-integration order. For a Bezier/BSpline-type curve, it correctly identifies the type via BAC.GetType() (a BRepAdaptor_Curve, whose GeomAdaptor_TransformedCurve::GetType() override correctly handles the curve-on-surface case), but then re-derives the pole count by hand via a completely different, non-virtual path: BAC.Curve().Curve(), down-cast to Geom_BezierCurve/Geom_BSplineCurve. BAC.Curve() returns the base GeomAdaptor_Curve sub-object, which holds the 3D-curve representation only, never Load()ed when the edge has no 3D curve (only a curve-on-surface pcurve), so the handle is null, the down-cast returns null, and ->NbPoles() dereferences it. This is exactly the shape of a degenerate edge BRepBuilderAPI_Sewing produces reconciling near-coincident vertices between two faces that don’t share an edge outright, surfaced sewing two real mesh-derived planar candidate faces (kof_ii_engine_cover.stl, regions 10 + 64) via a diagnostic dump added to OCCTReconstruct’s plane-select spike, then isolated with a custom SIGSEGV handler (lldb/core dumps unavailable in the diagnosing sandbox) that pinned the crash to IntegrationOrder. A from-scratch synthetic degenerate edge (BRep_Builder + a hand-built Geom2d_BSplineCurve pcurve on a plane, no 3D curve) reproduces the identical crash trace, the mechanism doesn’t depend on the specific fixture.

Fix, both layers. Bridge (OCCTShapeAnalyze’s small-edge scan) now skips degenerate edges outright, closes the crash immediately, on any xcframework, and is also a correctness fix: a degenerate edge’s zero 3D extent isn’t a “small edge” defect to flag. Kernel patch also carried (Scripts/patches/0006-BRepGProp_EdgeTool-use-adaptor-NbPoles-curve-on-surface-318.patch, xcframework rebuilt): IntegrationOrder now calls the adaptor’s own, correctly-dispatching BAC.NbPoles() (GeomAdaptor_TransformedCurve already has this override right next to GetType()) instead of manually re-deriving the pole count, no behaviour change for edges that do have a 3D curve.

Verification. The real sewn fixture and the synthetic degenerate edge both SIGSEGV 100% of the time on stock p1 and complete cleanly after the patch. New regression test Issue318DegenerateCurveOnSurfaceEdgeTests (OCCTShapeHealingTests), embedding the real sewn shape as a BREP fixture. Upstreamed as Open-Cascade-SAS/OCCT#1381 (repro) / OCCT#1382 (fix, with a GTest); the carried patch retires once it ships in the pinned kernel. Binary release, the xcframework changed, so Package.swift picks up the new URL + checksum; remote SPM consumers get the rebuilt binary.

  • Docs: CLAUDE.md’s Known OCCT Bugs entry added for #318; Scripts/patches/README.md documents patch 0006.

v1.12.7 (July 2026), fix: Shape.face(from:boundary:) no longer crashes on a single closed wire belting a cone (#317)

Root cause. ShapeFix_Face::FixPeriodicDegenerated(), invoked whenever a face’s sole boundary wire is a single closed edge belting a Geom_ConicalSurface’s full 2π period, apex outside the wire’s V range (a rivet/boss-rim seam fit as one periodic curve is the common source), builds a degenerate apex edge and finalizes with an unconditional Context()->Replace(myFace, myResult). Every other Context()->Replace call site in that OCCT source file, eleven of them, guards a null Context() first; this one didn’t. Context() is left null by ShapeFix_Root’s base constructor and set only by an explicit SetContext() call, which the ordinary ShapeFix_Face fixer(face); fixer.Perform(); (including ours, before this release) never makes, so any caller healing this exact wire shape null-derefs. Diagnosed with a custom backtrace_symbols_fd SIGSEGV handler (lldb/core dumps unavailable in the diagnosing sandbox) pinpointing the crash to FixPeriodicDegenerated; -O0 single-TU override-link tracing confirmed every prior statement in the function completes and the fault is specifically that call. A standalone wireFromEdges-only repro is negative, the crash needs the wire trimmed to a periodic surface via face(from:boundary:), which the original title’s suspicion of Wire.wireFromEdges itself never exercised.

Fix, both layers. Bridge (OCCTShapeCreateFaceFromSurfaceWire[WithHoles], OCCTFaceFixerCreate) now calls fixer.SetContext(new ShapeBuild_ReShape) before Perform(), closes the crash immediately, on any xcframework. Kernel patch also carried (Scripts/patches/0005-ShapeFix_Face-guard-null-context-FixPeriodicDegenerated-317.patch, xcframework rebuilt): restores the same if (!Context().IsNull()) guard used at every other call site in the file.

Verification. A synthetic 8-point closed periodic curve edge trimmed to a cone, healed with a bare ShapeFix_Face, SIGSEGVs 100% of the time on stock p1 and survives (valid healed face) after the patch; a 10-point curve fit through real mesh-derived rivet-rim points (the fixture this was originally surfaced from, railsim_581_lead.stl) behaves identically. New regression test Issue317PeriodicConicalSingleWireTests (OCCTSurfaceTests). Upstreamed as Open-Cascade-SAS/OCCT#1378 (repro) / OCCT#1380 (fix, with a GTest); the carried patch retires once it ships in the pinned kernel. Binary release, the xcframework changed, so Package.swift picks up the new URL + checksum; remote SPM consumers get the rebuilt binary.

  • Docs: CLAUDE.md’s Known OCCT Bugs entry added for #317; Scripts/patches/README.md documents patch 0005.

v1.12.6 (July 2026), fix: ShapeAnalysis_FreeBounds no longer crashes on disjoint free-boundary components (#310)

The real fix lands in the kernel. v1.12.5 documented the crash risk in freeBoundsClosedWires/freeBoundsClosedCount/freeBoundsOpenWires (and, it turns out, freeBounds too, same underlying constructor) because no reliable guard existed at the wrapper level. This release removes the risk entirely.

Root cause (found with AddressSanitizer). ShapeAnalysis_FreeBounds::SplitWire finds each wire’s closed sub-loops, then hands whatever edges weren’t consumed to ConnectEdgesToWires to chain into the “open” result. When a wire’s edges are entirely consumed by closed-loop detection, that hand-off is an empty (but non-null) sequence. The call chain ConnectEdgesToWires → ConnectWiresToWires → connectWiresToWiresImpl starts with if (iwires.IsNull() || !iwires->Length()) { return; }, for empty input this returns without ever assigning its owires out-parameter. Every caller in the file starts from a freshly-defaulted (null) handle, so the null propagates back through SplitWire’s open parameter into ShapeAnalysis_FreeBounds::SplitWires’s open->Append(tmpopen), dereferencing a null handle, an uncatchable SIGSEGV. Not a data-volume threshold: it depends only on whether any single free-boundary component happens to close with nothing left over, so a shape with 150+ loops can be fine while a 2-loop shape crashes (and vice versa), which is exactly why the #310 report’s own minimization (150-face fixture, sew ladder) came up empty while the real trigger, one call later in the pipeline, was easy to hit.

Fix. Scripts/patches/0004-ShapeAnalysis_FreeBounds-init-owires-empty-input-310.patch: the one-line contract restoration connectWiresToWiresImpl’s own non-empty path already follows a few lines down (owires = new NCollection_HSequence<TopoDS_Shape>; before populating it), “nothing to connect” now produces a valid empty result instead of an untouched out-parameter. The xcframework was rebuilt with the patch.

Verification. AddressSanitizer (macOS arm64, ModelingAlgorithms+ModelingData+FoundationClasses, RelWithDebInfo, MMGT_OPT=0): two disjoint planar faces in one compound crashed 100% of the time on stock p1, same function, same NCollection_Sequence::Append call, same 0xfffffffffffffff8 fault address at both -O2 and -O0, and now returns the correct 2 closed, 0 open. On the real #310 fixture (150-face analytic compound): tol=0.05 gives 152 closed/0 open byte-identical before and after (no behavior change on the working path); tol=0.10 crashed on stock p1 and now returns 144 closed/0 open. New regression test issue310DisjointFacesFreeBounds (OCCTShapeHealingTests). Upstreamed as Open-Cascade-SAS/OCCT#1377 (with a GTest), superseding the repro-only OCCT#1376; the carried patch is retired once it ships in the pinned kernel. Binary release, the xcframework changed, so Package.swift picks up the new URL + checksum; remote SPM consumers get the rebuilt binary.

  • Docs: removed the now-obsolete “can crash” warnings from freeBoundsClosedWires/freeBoundsClosedCount/freeBoundsOpenWires (doc comments + docs/reference/Document-Analysis-Builders.md); CLAUDE.md’s Known OCCT Bugs entry updated to record the fix.

v1.12.5 (July 2026), docs: correct #310’s crash diagnosis + isSelfIntersecting(timeout:) cooperative-bound wording (#310, #293)

PATCH, docs only, no code change. #310 reported Shape.sew/.healed()/.fixed(tolerance:) SIGSEGV-ing on a loose analytic-face compound, reproducing in a full pipeline but not via a standalone BREP replay. Investigation found the original diagnosis was off on two points, and root-caused the real defect:

  • Not Shape.sew/.healed()/.fixed(). The crash is one call later: Shape.freeBoundsClosedWires/ freeBoundsClosedCount (ShapeAnalysis_FreeBounds), called by the reporting pipeline immediately after a successful sew to print the free-boundary loop count. It only looked like the next sew call because that’s the next thing that runs.
  • Not process-state-dependent. The standalone probe that failed to reproduce it never called freeBoundsClosedWires/freeBoundsClosedCount, it only replayed sew/healed/fixed, so it never exercised the crashing function. Confirmed in pure C++ (no OCCTSwift) against the exact committed fixture, and reduced further to two disjoint planar faces in one compound with no shared geometry at all, not data-volume-dependent either (a shape with 150+ free-boundary loops can be fine while a 2-loop shape crashes).
  • Root cause (via a -O0 single-TU override-link of ShapeAnalysis_FreeBounds.cxx, same technique as #263): the uncatchable SIGSEGV is inside NCollection_HSequence<TopoDS_Shape>::Append, called from SplitWires’s per-wire result accumulation, with a valid (non-null) target handle, consistent with heap corruption originating earlier in the same function rather than a null-handle deref at the Append site. Not covered by the #298 fillet patch (checked: neither the sew/heal/fix path nor ShapeAnalysis_FreeBounds reference TopOpeBRepBuild/BlendFunc), and not a concurrency bug (the reporting ladder is single-threaded).

Fix. No wrapper-side guard is possible yet, there’s no reliable predicate distinguishing safe input from crashing input, so a defensive check would either miss the real trigger or reject valid shapes. Documented the crash risk on freeBoundsClosedWires/freeBoundsClosedCount/freeBoundsOpenWires (doc comments + docs/reference/Document-Analysis-Builders.md + CLAUDE.md’s Known OCCT Bugs list). Filed upstream as Open-Cascade-SAS/OCCT#1376 with both a minimal 2-face repro and the real-fixture repro; possibly related to the still-open OCCT#1330 (a different function in the same file, same “re-chaining free-boundary components” symptom family).

Also in this release (#293), isSelfIntersecting(timeout:)’s bound is cooperative, not a hard deadline. Shape.isSelfIntersecting(timeout:) documented its timeout as a wall-clock bound, but the mechanism (OCCTBoolTimeoutBreaker, a Message_ProgressIndicator whose UserBreak() trips past the deadline) can only fire when the running OCCT algorithm polls, BOPAlgo_ArgumentAnalyzer’s self-interference phase has at least one long checkpoint-free stretch (observed 20+ minutes past a 30s bound on a pathological B-spline solid), during which the calling thread is blocked inside the call with no way to return early. The boolean ops’ own timeout (#206) is unaffected, their polled path is verified to interrupt correctly; this is specific to the self-interference check. Corrected the doc comment (Shape.swift), docs/reference/Shape-Features.md, and docs/guides/cookbook/healing-and-validity.md to state the bound is cooperative and can overrun arbitrarily in an un-polled phase, with process-level isolation as the only true hard bound. No API or behavior change.

v1.12.4 (July 2026), fix: drilled honours its direction; face(outer:holes:) respects hole winding; docs audit made type-aware

Two behaviour bugfixes and a documentation-coverage pass. No public API change (derived operation count unchanged at 4,241).

drilled(at:direction:radius:depth:) now bores along direction (#272). The bridge built the drill cutter via a +Z-hardcoded cylinder (OCCTShapeCreateCylinderAt), so drilling along any non-Z axis silently bored straight up Z, often removing nothing when the repositioned base fell outside the shape. It now uses OCCTShapeCreateCylinderOriented, whose gp_Ax2(entryPoint, direction) orients the bore along the requested (normalized) axis; a zero-length direction returns nil.

face(outer:holes:) no longer double-flips an already-opposite hole (#274). Every hole wire was reversed unconditionally before BRepBuilderAPI_MakeFace.Add, but Add does not normalise hole orientation, so a hole passed already wound opposite the outer (the correct winding) was flipped back to wrong, yielding an invalid face or an added-instead-of-subtracted hole. Reversal is now conditional: the outer’s plane is found, an arc-aware signed area decides each wire’s winding, and a hole is reversed only when it winds the same way as the outer. Per-hole, so mixed-winding hole sets work too; falls back to the legacy reverse when no plane can be determined.

Scripts/count-operations.py --audit is now type-aware (#294). The old matcher compared a counted entry point’s bare name against doc headings with no notion of the owning type, so it over-reported generic names (get, cols, z, …) that were documented, and missed multi-symbol headings (### `isCylinder`, `isCone`, `isSphere`) entirely, capturing only the first name. Of the 37 it flagged, 26 were false positives; the matcher now carries a Type.name identity on both sides and parses multi-span headings. The 11 genuine gaps (oriented bounding box, OSD_Environment accessors, and LocalizedError.errorDescription on seven error enums) are now documented, --audit reports 0.

v1.12.3 (July 2026), fix: concurrent fillet/chamfer fixed in the kernel; serialization lock removed (#298)

The real fix for #298 lands in the pinned OCCT, and the interim bridge lock is gone, concurrent fillet/chamfer run in parallel again. v1.12.1 stopped the corruption by serialising every 3D fillet/chamfer build behind a bridge mutex (occtFilletMutex); that was correct but cost the parallelism. This release fixes the root cause in the kernel and drops the lock.

Root cause (found with ThreadSanitizer). BRepFilletAPI_MakeFillet reconstructs its result solid through OCCT’s legacy TopOpeBRepBuild boolean engine (ChFi3d_Builder::Compute → TopOpeBRepBuild_HBuilder::MergeSolid → TopOpeBRepBuild_Builder::SplitSolid), which passed state between methods through a file-scope static, STATIC_SOLIDINDEX: SplitSolid sets it to 1/2 to tell FillSolid which operand it is splitting, and FillSolid reads it back to pick the operand shape. Two fillet builds on independent shapes on separate threads clobbered each other’s flag, so FillSolid mis-classified faces and returned a wrong-but-plausible solid (one solid, positive volume, fails BRepCheck). This is not the BlendFunc scratch the v1.12.1 notes first suspected, those statics do race, but benignly; STATIC_SOLIDINDEX alone accounts for the corruption.

Fix. Scripts/patches/0003-TopOpeBRep-non-reentrant-globals-fillet-298.patch converts the fillet-path statics to thread_local (each thread keeps its own copy; single-thread behaviour is unchanged): STATIC_SOLIDINDEX and STATIC_lastVPind (functional), plus the BlendFunc_ConstRad/EvolRad and ChFi3d_Builder checkcurve scratch (benign, converted so the path is TSan-clean). The xcframework was rebuilt with the patch, so the kernel is reentrant and the occtFilletMutex guard (16 bridge call sites) was removed. Upstreamed as Open-Cascade-SAS/OCCT#1374; the carried patch is retired once it ships in the pinned kernel.

Verification. Pure-C++ 8-thread stress: 0/1600 concurrent fillet builds invalid with a single correct volume (was ~15–20% corrupt), and ThreadSanitizer reports the fillet path clean. Issue298FilletThreadSafetyTests now passes with the lock removed. Binary release, the xcframework changed, so Package.swift picks up the new URL + checksum; remote SPM consumers get the rebuilt binary.

v1.12.2 (July 2026), fix: graph construction now runs OCCT’s Clear() rebuild boundary (#303)

Every graph OCCTSwift built reported generation == 0 and an all-zero GraphGUID. The bridge built a graph with the constructor plus Shapes().Add(shape), and never called BRepGraph::Clear() , which upstream treats as the rebuild boundary (PR #1237) and is the only call that stamps a graph’s identity (IncrementGeneration() + SetGraphGUID(random)). Skipping it left the kernel’s own version-stamp machinery unarmed on our path: GraphGUID stayed the default all-zeros, so BRepGraph_VersionStamp::ToGUID: documented as making per-node GUIDs “globally unique across different graph instances”, would have hashed in the zero GUID and returned identical GUIDs for different graphs. Nothing user-visible broke (none of GraphGUID / StampOf / IsStale / ToGUID is wrapped), but it was a live trap for whoever wraps that surface next.

Surfaced while fixing #295, and verified independent of it: giving graphs real GUIDs does not stop a foreign UID resolving, because BRepGraph_UID is (Kind, Counter) and carries no GUID for NodeIdFrom to compare. #295’s instanceID provenance check is still needed and unchanged.

Fix. OCCTBRepGraphCreate and OCCTBRepGraphCopyFace now call graph.Clear() before ingesting the shape, matching upstream’s declared lifecycle. copy() / translated() deliberately do not: BRepGraph_Copy/_Transform::Perform transplant the source’s whole identity (generation + GUID) into the target, so a pre-Clear() would just be overwritten, the inheritance is what we want. copyFace() does get a Clear(): it is a fresh build with counters restarting at 1, and ground truth on the pinned 8.0.0p1 kernel confirms CopyNode does not transplant the source GUID, so the fresh stamp survives and matches the graph’s fresh instanceID.

Verified safe first (the issue’s load-bearing unknown): Clear() calls LayerRegistry::ClearAll(), which the header documents as clearing layer data “without unregistering services”, so the BRepGraph_LayerHistory layer the constructor registers, which #290’s add(_:absorbing:…) depends on, survives. Ground-truth-confirmed against the pinned kernel: after Clear()-then-Add(), the history layer is still registered and recording works; the #290 history-absorb suite and the #295 provenance suite both stay green.

  • Changed: TopologyGraph.generation is now a constant 1 (was 0). Still deprecated and still useless as identity, it is the same 1 for every graph. Use instanceID to compare graph identity.

v1.12.1 (July 2026), fix: concurrent 3D fillet/chamfer builds no longer corrupt each other (#298)

Filleting a shape on two threads at once returned wrong-but-plausible geometry. Reported as SheetMetal.Builder.build returning an invalid solid under parallel test execution (~8 of 10 runs), but the root cause is upstream and independent of the wrapper. BRepFilletAPI_MakeFillet’s constant- and evolutive-radius blend solvers (BlendFunc_ConstRad, BlendFunc_EvolRad) and the shared ChFi3d_Builder curve checker keep their geometric work variables in function-local statics, process-global state, “to avoid systematic reallocation”. Two threads filleting at once interleave writes to those statics, the solver converges on a corrupted surface, and the result is a solid with one shell and a positive volume that nonetheless fails BRepCheck, silent bad geometry, not a crash and not a thrown error.

Reproduced in pure OCCT with no OCCTSwift code involved: a fuse-then-fillet on eight threads produced BRepCheck-invalid solids with volumes scattered across several wrong values, while the same build on one thread was bit-for-bit deterministic and correct. A plain box fillet (which takes OCCT’s analytic ChFiKPart fast path, not the numerical blend) is unaffected; only filleting a boolean result, which needs the general path, trips it.

The issue’s own diagnosis was corrected on three points: the result is not an empty shape (so the suggested “reject empty results” guard would not have caught it), the boolean is not implicated (the fuse is thread-safe and returns the correct shape every time), and it is unrelated to the NCollection arm64 SEGV.

Fix. The bridge now serialises every 3D fillet and chamfer build under a dedicated recursive mutex (occtFilletMutex), distinct from OCCTSerial. Fillet/chamfer are now always safe to call concurrently with no caller-side lock; booleans, meshing, sweeps, and everything else stay fully parallel, only fillet/chamfer builds serialise against each other. 2D fillets (BRepFilletAPI_MakeFillet2d, the analytic ChFi2d toolkit) have no such statics and are not guarded. Verified: the originally-failing OCCTMiscTests target passes 8/8 parallel runs, and a new Issue298FilletThreadSafetyTests regression fails reliably without the lock and passes with it.

This is a mitigation. The permanent fix de-statics the work variables in OCCT itself so the lock can be dropped and fillet/chamfer become genuinely parallel, tracked as a follow-up occt-src patch and an upstream report. See docs/thread-safety.md for the full write-up.

v1.12.0 (July 2026), fix: a GraphUID no longer resolves against a graph that didn’t mint it (#295)

node(forUID:) returned a wrong node instead of nil for a UID from an unrelated graph. GraphUID carried no graph identity: it is a (kind, counter) pair, and every TopologyGraph allocates counters from 1 independently. A UID minted from a box therefore landed inside a cylinder graph’s valid counter range and resolved cleanly, to an unrelated face. contains(uid:) returned true. No error, no nil, just a plausible wrong answer:

let boxUID = boxGraph.uid(ofNodeKind: 2, index: 2)!   // a face of the BOX
cylGraph.node(forUID: boxUID)     // was: Optional((kind: 2, index: 2)), now: nil
cylGraph.contains(uid: boxUID)    // was: true, now: false

The documented safeguard could never have caught it. generation is a constant 0, and the staleness recipe the docs gave compared it against storedOwnGen, a per-entity mesh field that was never the same counter.

Fix. Every graph carries an instanceID, and every UID it mints records it as graphID. node(forUID:) / contains(uid:) / ref(forUID:) / item(forUID:) reject a UID from any other graph. The id follows the same lifecycle rules as OCCT’s own graph identity (GraphGUID), which OCCTSwift cannot read because the kernel only populates it in BRepGraph::Clear(), a call our build path skips (see #303).

Identity follows the kernel’s rule: whether an operation transplants the UID counter space:

Operation Identity UIDs
compact(), node removal, add(_:absorbing:…) same instance keep resolving
copy(), translated() inherited keep resolving, naming the same nodes
copyFace() fresh source UIDs now return nil (they returned a wrong face before)
a new graph over any shape, incl. a rebuild fresh source UIDs now return nil

copy() and translated() are unaffected: BRepGraph_Copy/_Transform::Perform transplant the counter space, Generation and GraphGUID into the target, so a copy genuinely is the same identity and every source UID resolves to the same node. Only copyFace(), which lifts one face into an empty graph, restarting counters at 1, aliased, and it is now rejected.

  • New: TopologyGraph.instanceID; graphID on GraphUID, GraphRefUID, GraphItemUID.
  • Deprecated: TopologyGraph.generation, always 0, guards nothing. Also the hand-built GraphUID(kind:counter:) initializers: a UID with no provenance resolves in no graph, so mint them with uid(ofNodeKind:index:).
  • Immutable fields: kind / counter / domain are now let. A mutable counter beside an immutable graphID let a caller forge provenance, mutate a minted UID’s counter and it would resolve to an arbitrary node, the exact bug this release closes. Mutating a UID was never coherent and no code in the ecosystem did it, but this is technically source-breaking for anyone who did.
  • Persistence: a UID does not survive a rebuild, and never legitimately did, it only appeared to, because rebuilding the same shape re-allocates counters identically, with nothing checking it was the same shape. Store (kind, index) with the shape and re-mint after rebuilding. This matches OCCT’s model, where a UID is an anchor into a persisted graph model; OCCT does not yet expose a graph serializer, and its GraphGUID is regenerated on every rebuild by design.
  • Codable: payloads written before this release have no graphID and decode as unstamped (graphID == 0), which resolves nowhere rather than failing the load.
  • Equality: graphID participates in Hashable/Equatable, so UIDs from unrelated graphs no longer compare equal or collapse together in a Set. Within one graph (and across a copy), equality is unchanged.

Unchanged: a UID still survives compaction and node removal within its own graph, that is what it is for, and it is now the property the tests actually check. The previous foreignUIDDoesNotResolve test only fabricated an out-of-range counter, which the reverse-index rejected anyway; a genuinely foreign UID is in-range. Surfaced while investigating #290.

v1.11.3 (July 2026), fix: robust importers silently dropped all but the first body (#302)

A multibody file lost every body after the first. Ten boxes in, one box out, no error, no diagnostic, and a perfectly valid solid returned. Found while sweeping the robust import paths for #300; it is a data-loss defect rather than a progress one, so it was filed and fixed separately.

Every robust importer sewed and then took the first shell only:

TopExp_Explorer shellExp(sewedShape, TopAbs_SHELL);
if (shellExp.More()) {                                    // <-- first shell, no loop
    BRepBuilderAPI_MakeSolid makeSolid(TopoDS::Shell(shellExp.Current()));
    if (makeSolid.IsDone()) resultShape = makeSolid.Solid();
}

Measured on a 10-box compound (10 solids, 60 faces), through the public API:

API before after
Shape.loadRobust (STEP) 1 solid, 6 faces 10 solids, 60 faces
Shape.loadSTLRobust 1 solid, 12 faces 10 solids
Shape.loadWithDiagnostics 1 solid, 6 faces 10 solids, solidsCreated == 10

The sewing was never at fault, BRepBuilderAPI_Sewing returns one shell per body, and the bridge discarded nine of them. Shape.load / loadSTL (the plain loaders) were never affected, and loadIGESRobust is not either: the IGES path only transfers and heals, so it has no MakeSolid step to truncate.

Behaviour change, the return type now follows the file. A multibody import returns a compound of solids; a single-body import still returns a plain solid, exactly as before, so existing single-body callers are untouched. Callers that handle both must not assume .solid.

ImportResult.solidsCreated: Int (new) reports how many shells became solids, alongside the existing solidCreated: Bool. The count is precisely the fact that was silently wrong.

Shells that MakeSolid rejects are now carried through as shells rather than dropped, losing them quietly is the defect being fixed.

The fix walks a compound’s immediate children rather than exploring for shells, because an explorer descends into solids: a hollow body owns an outer shell plus one per void, and solidifying those separately would split one body into two, trading data loss for corruption. A regression test covers it (a box with an internal spherical void survives with both shells and its exact volume).

Regression tests assert on body count, not validity. That distinction is the point: a truncated import returned a well-formed solid and isValid was true throughout, which is why this shipped unnoticed. Same lesson as #286/#300, assert the property that was actually broken.

v1.11.2 (July 2026), fix: robust-import healing ran outside the caller’s progress range (#300)

Shape.loadIGESRobust now honours a deadline during healing. The sweep of the remaining *Progress entry points that #299 called for found the #286 constructor pattern does not recur, the other entry points all hand the range to a range-taking method. But it found the same family of defect: OCCTImportIGESRobustProgress gave TransferRoots the entire Message_ProgressRange and then ran ShapeFix_Shape::Perform() with no range at all. Healing is not a coda to a robust import, measured at 38–50% of transfer+heal across box/sphere/cylinder/torus compounds, so a caller’s deadline could not bound roughly half the call. shouldCancel() returning true during healing was ignored entirely: the heal ran to completion and the import returned a shape rather than reporting cancellation.

Fixed with a Message_ProgressScope subdividing the range: transfer takes fraction 0…0.5, healing 0.5…1.0. This changes the reported fraction curve, transfer previously spanned 0…1.0 and then the import paused silently and uncancellably. The even split is what the measurements support, not a guess; and because the scope closes out on destruction, fraction still reaches 1.0. Wiring a live range into healing costs nothing measurable (−4.7%, i.e. noise, over 3 reps).

ShapeFix_Shape::Perform(range) and BRepBuilderAPI_Sewing::Perform(range) were both verified to honour the break, not merely poll it, 1.85 s full heal vs 0.005 s cancelled, and a mid-flight deadline interrupted at 0.478 s against a 0.463 s budget. Since the abort leaves a partially-healed shape behind, the bridge now reports cancellation rather than handing that back.

The regression test asserts on elapsed time with the deadline set past the transfer, so it lands inside healing, the part that was unreachable. Confirmed to fail against the old bridge: “loadIGESRobust returned a shape instead of cancelling”. A cancel triggered on reported fraction would have been a false negative: under the old bridge the transfer alone spanned 0…1.0, so any fraction-based trigger fired while the transfer was still running and cancelled correctly even with the bug present.

Shape.loadRobust gains a progress: channel (new API). OCCTImportSTEPRobustProgress had the identical defect, and was fixed identically (transfer/sew/heal, with sewing taking a thin slice of the repair half since it costs ~1% of what healing does), but no Swift API reached it: loadRobust called the non-progress bridge variant, so a robust STEP import could not be observed or cancelled at all. It now routes through the progress-capable variant, mirroring its loadIGESRobust sibling:

let shape = try Shape.loadRobust(from: stepURL, progress: Deadline())

Source-compatible, progress defaults to nil, so existing loadRobust(from:) call sites are unaffected. The one visible change is the failure message for the URL overload, which now carries the full path rather than the last component (it delegates to the path overload, as loadIGESRobust does).

Exposing it is also what makes the STEP path testable: the new regression test drives it through a convex N-gon prism, which imports as a single many-faced solid and so takes the SOLID branch, where repair is ~50% of the work. That share is load-bearing, on a compound the same import spends only ~6% in repair, so a deadline would land in the transfer, which was already cancellable, and the test would pass with the bug present. Confirmed to fail against an unfixed repair phase: “loadRobust returned a shape instead of cancelling”.

Also in this release:

  • Docs corrected: loadIGESRobust was documented as “sewing and healing”. It has never called BRepBuilderAPI_Sewing: it only transfers and heals.
  • Documented OCCT limitation: IGESControl_Reader::ReadFile takes no Message_ProgressRange (verified against the pinned V8_0_0_p1 headers), so parsing happens before the indicator exists and can be neither reported nor cancelled. The same is true of STEPControl_Reader::ReadFile and the STEP/IGES writers. Stated rather than papered over, per the #286 lesson.
  • Redundant Perform() dropped from OCCTExportSTL/OCCTExportSTLWithMode, whose constructor already meshes. Measured as redundant, not a 2× cost: 0.0003 s against a 1.29 s mesh.

v1.11.1 (July 2026), fix: meshWithProgress could never cancel; retract the #286 kernel story (#286)

Shape.meshWithProgress now actually cancels. The bridge used the BRepMesh_IncrementalMesh(shape, linDefl, isRelative, angDefl) constructor, which calls Perform() internally with a null Message_ProgressRange. The entire mesh was therefore built uninterruptibly inside the constructor, before the range we passed to the following Perform(range) was ever polled, and that second call meshed the shape a second time. Cancellation still threw, because UserBreak() was checked afterwards, so the pre-existing test passed and the defect shipped. Fixed by using the IMeshTools_Parameters + Message_ProgressRange constructor, the only one that consumes a range. Meshing behaviour is otherwise unchanged: both constructors leave AngleInterior/MinSize/DeflectionInterior at defaults, which Perform() resolves identically.

Measured on the #286 face (249 s to mesh in full): a 10 s deadline now throws ImportError.cancelled after 10.1 s, having polled 154,898 times. Previously it ran past 400 s without cancelling.

v1.10.2’s account of #286 was wrong in every substantive claim, and is retracted. Each was checked by measuring or building it rather than by reading:

Claim (v1.10.2) Measured
Shape.mesh hangs unboundedly on offset surfaces Terminates in 249 s, status=0, 1.4 M triangles. Earlier “hangs” were 120 s / 300 s timeouts set below that.
No in-process timeout can bound it, “measured, not inferred” A 10 s deadline returns in 10.1 s. The “10 s cancel never fired” measurement was our own meshWithProgress bug, above, not an OCCT limitation.
Root cause is BRepMesh_MeshAlgoFactory::GetAlgo handing offsets a BRepMesh_UndefinedRangeSplitter Disproven by building it. Routing GeomAbs_OffsetSurface to BRepMesh_NURBSRangeSplitter leaves the runtime identical. getUndefinedIntervalNb() is dead code here: NbUIntervals(CN) forwards to the basis adaptor and returns 11, not 1, so the if (aIntervalsNb == 1) branch never runs and the two splitters behave identically. (NbUPoles() also throws Standard_NoSuchObject on an offset adaptor, so the proposed one-liner was unsafe regardless.)
The hang is in BRepMesh_Delaun::createTrianglesOnNewVertices Stack samples put 100 % of time in BRepMesh_DelaunayDeflectionControlMeshAlgo::optimizeMesh.

Actual cause, invalid input, not an OCCT defect. The offset surface is self-intersecting. Offsetting by more than the local radius of curvature produces cusps: the #286 basis fit’s minimum principal curvature radius is 2.6e-05 against an offset of 1.27, so 23.8 % of its domain is cusped and the surface normal swings by up to π across one. BRepMesh splits any triangle link whose end normals differ by more than AngleInterior (= 2 × angularDeflection), but at a normal discontinuity splitting never converges, halving a link that straddles a cusp just moves the cusp into one half. So optimizeMesh runs all 11 passes demanding ~80 k splits each, long after linear deflection is satisfied (1.82 against a 2.48 target by pass 6, with linear splits at zero from pass 4); MinSize (= linearDeflection / 10) is the only backstop, rejecting ~200 k splits per pass.

No upstream OCCT issue or patch is warranted: retracting v1.10.2’s “an upstream OCCT fix is being attempted”. No xcframework rebuild either: the binary is unchanged. A well-formed offset surface meshes normally.

Shape.mesh and Shape.meshWithProgress docs and docs/reference/Shape.md rewritten against the measurements, and the mitigation list now leads with the deadline that actually works. The bounds pre-check remains the best first line of defence, and isValid still will not catch this (a self-intersecting offset surface is a topologically valid face).

v1.11.0 (July 2026), feat: absorb a boolean’s history into the graph, so a picked face survives it (#290)

Holding a reference to a picked face across an operation that rebuilds the shape had no supported path. ShapeHistoryRef (TopoDS-level, from the *WithFullHistory helpers) and the TopologyGraph history log were two disconnected systems: the boolean never wrote a record into the graph’s log, so resolve(.splitOf(…)) / .createdBy(…) / currentForms(of:) had nothing to walk and callers were left correlating ShapeHistoryRecord.modified back to graph nodes by hand, in practice by geometry.

OCCT 8.0.0p1 already ships the bridge; it was simply unwrapped. BRepGraph::ShapesView::AddWithHistory collects the input map via CollectHistoryInputs, the output map via Options::TrackAddedNodes, and hands both to BRepGraph_LayerHistory::Absorb.

New

  • TopologyGraph.add(_:absorbing:inputRoots:operationName:): add an operation’s result to the graph and absorb its history. Afterwards the entities you already held resolve to their successors.
  • TopologyGraph.historyIsDeleted(_:) / .historyDeletedNodes, distinguish “consumed by the operation” from “never touched”. Absence of a record is not deletion.
let graph = TopologyGraph(shape: base)!
let root = graph.findNode(for: base)!               // topology root, NOT rootNodes (see below)
let topNode = graph.findNode(for: topFace)!         // pin the face BEFORE the cut
let pinned = TopologyGraph.NodeRef(kind: topNode.kind, index: topNode.index)

let (result, history) = base.subtractedWithFullHistory(tool)!
graph.add(result, absorbing: history,
          inputRoots: [TopologyGraph.NodeRef(kind: root.kind, index: root.index)],
          operationName: "channel-cut")

let strips = graph.currentForms(of: pinned).filter { $0.kind == .face }   // the two successors
graph.resolve(.splitOf(original: .literal(pinned), occurrence: 0))        // .success(face)

One graph, not two. AddWithHistory resolves its input roots against the receiving graph, so the input and the result share one graph and history is NodeId-keyed. The NodeRefs and GraphUIDs a caller already holds stay valid: there is no generation boundary to cross and no cross-graph UID resolution, which sidesteps the aliasing hazard in #295 entirely. Build the graph from the operation’s input, then hand it the result. The two-graph Absorb overload and the UID-keyed record path (RecordUid / HasKnownInput) are deliberately left unwrapped: strictly more dangerous, and no consumer.

Works for all nine *WithFullHistory ops. OCCTBooleanHistoryAsBRepToolsHistory synthesizes a real BRepTools_History from the retained builder via the (arguments, algo) template constructor, which needs only Modified / Generated / IsDeleted, all virtual on BRepBuilderAPI_MakeShape. That matters because only the BRepAlgoAPI_* builders expose a native History(); fillet, chamfer and thick-solid do not. OCCTBooleanHistory now retains its arguments, since a type-erased builder cannot report its own inputs.

Known edges, documented rather than papered over:

  • currentForms(of:) returns the cut’s new section edges alongside the split faces, because BRepGraph_LayerHistory::FindDerived unions Modified and Generated descendants transitively. Filter by .kind when you want only faces. Existing behaviour, unchanged.
  • Only vertices, edges, faces and solids are carried, BRepTools_History::IsSupportedType tracks nothing else, so absorbing records nothing for wires, shells or compounds.
  • TopologyGraph.rootNodes is Products, and shape-built graphs set CreateAutoProduct = false, so it is always empty for them. The topology root is findNode(for: inputShape). This trips people up; the reference page now says so.

v1.10.3 (July 2026), docs: canonical operation count, derived not hand-maintained (#289)

The headline operation count was stated in two places with three numbers in play: README said 4,313, docs/API_REFERENCE.md’s Total said 3,431, and that table’s own 470 category rows summed to 3,320. Both headline figures were last written in the same commit, so at most one was ever right, and both predated v1.10.0.

Canonical rule, now written down (docs/API_REFERENCE.md § How operations are counted):

One row per distinct public Swift entry point; overloads counted separately.

An operation is any public func/static func, public init, public var with an accessor block, or public subscript in the OCCTSwift module. Stored properties, types and enum cases are data, not entry points, and are not counted. The derived count is 4,234.

Derived, not hand-maintained. Scripts/count-operations.py computes it from source and rewrites both figures (--fix), exiting 1 if they disagree, the drift class is now mechanically impossible. --audit lists counted entry points with no reference page.

The 882 gap turned out to be exactly what #289 suspected: two divergent methodologies. README’s 4,313 was ~the full entry-point surface (80 off today’s derived 4,234, stale, not wrong-in-kind), while API_REFERENCE’s rows are a curated categorisation covering 3,320 (~78%) of the surface. The Total and the row sum were never measuring the same thing; the table now says so explicitly rather than implying the rows should add up.

Docs coverage audit (the same pass): 39 counted entry points had no reference documentation. Two were counted in API_REFERENCE’s own example lists while being undocumented, Shape.commonAll(_:) (Booleans) and hollowed(removingFaces:thickness:tolerance:joinType:) (Modifications), both now documented beside their siblings. The remaining 37 are tracked in #294.

v1.10.2 (July 2026), docs: Shape.mesh can hang unboundedly on offset surfaces (#286)

Retracted by v1.11.1. Every substantive claim below is false: the mesh is not unbounded (249 s), cancellation does work (the failed 10 s deadline was our own bridge bug), and the splitter root cause was disproven by building the proposed fix. Kept for history; see v1.11.1.

Documentation only; no code change, because there is no correct in-process code change to make, and saying so precisely is the useful output.

Shape.mesh(linearDeflection:angularDeflection:) can put OCCT’s mesher into an effectively non-terminating state on the Geom_OffsetSurface geometry that shelled(thickness:) / offset(by:) produce. Reproduced standalone from a real fitted-then-offset B-spline panel: a single face meshed for >300 s without returning, at a coarse deflection (2.48 against a 1583 bbox diagonal, 1/638). A kernel pathology, not a workload cost.

Root cause (OCCT V8_0_0_p1): BRepMesh_MeshAlgoFactory::GetAlgo lumps GeomAbs_OffsetSurface in with GeomAbs_OtherSurface, handing it a BRepMesh_UndefinedRangeSplitter whose getUndefinedIntervalNb() returns a constant 1. An offset surface therefore gets no parametric subdivision at all, however wiggly its basis B-spline is, while that same B-spline meshed directly gets BRepMesh_NURBSRangeSplitter (NbUPoles()-1 intervals). The Delaunay insertion starts from a near-empty grid and BRepMesh_Delaun::createTrianglesOnNewVertices blows up.

Two things this is not, both worth recording because both were the obvious first guesses:

  • Not fixable with a timeout, measured, not inferred. createTrianglesOnNewVertices does poll (aPS.More()) in its outer per-vertex loop, but the hang is inside a single iteration, so the poll is never reached. A 10 s cancel deadline via meshWithProgress was measured not to fire at all (killed at 120 s). meshWithProgress’s docs previously implied a cancellation guarantee it cannot honour; they now state the checkpoint granularity explicitly.
  • Not catchable by a validity pre-check. The offending solid reports isValid == true.

Mitigations documented on mesh, best first: sanity-check with bounds (a cheap Bnd_Box query, no tessellation) before meshing untrusted offsets; withSurfacesAsBSpline(offset: true), which converts the offset surface to a plain B-spline and turns the hang into a bounded 125 s / 526 k verts, this also explains why that mitigation works, since it routes the surface to the correct splitter (a rescue path, not a default); or mesh out-of-process.

An upstream OCCT fix is being attempted against the root cause; see #286.

Well-formed offset solids are unaffected.

v1.10.1 (July 2026), OCCT rebuild carrying the #280 kernel fix; consumer builds are warning-free (#281)

Rebuilt OCCT.xcframework (all three slices) carrying a new carried patch, 0002-STEPControl_Writer-initialize-missing-shape-processing-1334.patch: a backport of upstream OCCT#1334 (merged 2026-07-10), which lands after our V8_0_0_p1 pin (2026-06-16). This fixes #280 in the kernel, so the v1.9.2 bridge workaround (repairSTEPWriterActor, which installed a plain controller on each of the 8 shape-level write paths) is removed, verified by deleting it and confirming the regression test still passes against the rebuilt binary.

Build warnings: 797 → 0 (#281).

  • 684 -Wdeprecated-declarations → 0. OCCT 8.0 deprecates its own legacy spellings (Standard_True, Standard_Real, TopTools_*, TColStd_Array1Of*, …) which this bridge still uses. Defining OCCT’s own OCCT_NO_DEPRECATED opt-out on the OCCTBridge target silences exactly those attributes and nothing else. Deliberately a .define and not .unsafeFlags, SwiftPM rejects unsafeFlags in any package consumed as a dependency, which would break every downstream consumer. This buys quiet, not absolution: migrating the call sites off the legacy spellings is still tracked in #281.
  • 23 -Wshorten-64-to-32 → 0. All 23 were the same shape, an NCollection container’s .Size() (size_t) assigned to int32_t/int. Now explicit static_cast. To be clear, these were not latent bugs: unlike the quantize() Int32 overflow (OCCTSwiftViewport#30), a container with

    2^31 elements is not reachable here. The casts document intent and stop the noise.

  • Swift hygiene. Removed four dead bindings in SheetMetal.swift (aOuter1/bOuter1, arcNormalCandidate, seamLength), each was leftover from a superseded approach that the surrounding comments already described, so the stale comments went too; none was an unfinished calculation. Mesh.swift var→let. Also fixed two warnings not listed in #281 that a cached build had been hiding: a deprecated String(cString:) in BRepGraph.swift (decoding now stops at the bridge’s NUL terminator, String(decoding:as:) over the whole fixed 128-byte buffer would have carried the NUL padding into the string) and a deprecated union(with:) in OCCTTest.

Full suite: 4,359 tests, 0 failures. Consumer builds inherit no warnings from this package.

Bumped PATCH: no public API change, a kernel rebuild, a workaround removal, and build hygiene.

v1.10.0 (July 2026), feat: allEdgePolylinesIndexed, bulk wireframe with pick identity (#275 follow-up)

allEdgePolylines is dense: when a degenerate/failed edge is skipped (a sphere’s pole seams, a scan’s broken edge), every later polyline shifts down, so a polyline’s position no longer equals its edge index, consumers that round-trip wireframe back to topology (per-segment edge pick indices, polyline → TopoDS_Edge) silently mis-map from the first skip onward.

New: Shape.allEdgePolylinesIndexed(deflection:maxPointsPerEdge:) -> [(edgeIndex: Int, points: [SIMD3<Double>])], the same single O(edges) bulk pass (#275), with each polyline carrying its original edgePolyline(at:) / edge(at:) index. allEdgePolylines now delegates to it (.map(\.points)) , dense output unchanged, byte-identical.

The first consumer is OCCTSwiftTools’ extractEdgePolylines (the shapeToBodyAndMetadata wireframe pass), whose per-index loop was the O(edges²) hot path that hung OCCTMCP’s render_preview on mesh-scale STL imports (OCCTMCP#75).

New test: sphere fixture proves indices survive a real skip (returned pairs match the per-index accessor exactly; skipped indices are exactly those the per-index accessor rejects).

v1.9.2 (July 2026), fix: an XDE STEP read silently corrupted every later STEP write (#280)

Reading a STEP through Document.loadSTEP permanently corrupted every subsequent Exporter.writeSTEP in the process. A cone frustum wrote as a 2-face solid missing its lateral CONICAL_SURFACE and 63% of its volume (408.407 → 151.844), still reporting isValid == true. Read a STEP, write a STEP, geometry silently gone, an ordinary app sequence.

Upstream OCCT bug. STEPCAFControl_Controller’s constructor overwrites the actor its base class just configured, without re-applying SetShapeProcessFlags, then AutoRecord()s itself under the same "STEP" name the plain writer resolves by, and STEPControl_Writer::SetWS() unconditionally re-runs SelectNorm("STEP"). So after any XDE read (a STEPCAFControl_Reader merely being constructed is enough) every shape-level write ran with empty OperationsFlags: DirectFaces never ran, and faces on indirect (left-handed) surfaces, a frustum’s cone, were dropped. That is why only the cone was affected; box/cylinder/sphere/torus have no indirect surfaces.

Fixed upstream in OCCT PR #1334 (merged 2026-07-10), which added an InitializeMissingParameters() call to STEPControl_Writer::Transfer. Our pinned V8_0_0_p1 (tagged 2026-06-16) predates it, it defines that method but never calls it, and it is private. The bridge therefore installs a freshly-constructed plain controller on each shape-level write, restoring the flags the writer should have had. Retire the workaround when the bundled OCCT moves past that commit.

This was also the cause of the long-standing cone() failure in StressFormatRoundTripTests, which passed in isolation and failed in every full run purely because OCCTIOTests reads a STEP first. It was never flaky, it was correctly reporting this bug. The full suite is now green: 4,359 tests, 0 failures.

Bumped PATCH: bug fix, no public API change.

v1.9.1 (July 2026), fix: a new Document could inherit a dead Document’s construction context (#277)

Document.constructionContext is resolved through a side table keyed on ObjectIdentifier(document) , the raw instance pointer, which is unique only among live objects. The entry was never removed (a clear(for:) existed but was never wired to Document.deinit), so a context outlived its document; the allocator then readily handed the same address to the next Document, which resolved to the dead one’s context and silently inherited its entities.

This was not a rare race. In a tight create/destroy loop every new Document reused the address and accumulated its predecessors’ entities monotonically (1 → 2 → 3 → …). It surfaced as an intermittent failure in the materializeAll() test, 4 entities materialized where 3 were added, but the same fault hits any app that creates and releases documents over its lifetime: fresh documents silently carrying dead ones’ construction geometry, plus an unbounded leak of every ConstructionContext ever created.

Document.deinit now clears the association before the instance’s memory can be recycled. No API change, the documented guarantee (one context per Document instance, released with it) is simply true now. DocumentAssociatedStorage carries a warning that owners must clear on deinit, since the pattern silently reintroduces this if they don’t.

Bumped PATCH per the cohort SemVer policy: bug fix, no public API change.

v1.9.0 (July 2026), perf: allEdgePolylines is O(edges), not O(edges²) (#275)

Shape.allEdgePolylines looped edgePolyline(at:), and every one of those calls rebuilt the shape’s full TopTools_IndexedMapOfShape, so extracting a wireframe was quadratic in edge count. Measured on a box compound: 12,288 edges went from 15.5 s to 0.017 s (~900x); 3,072 edges from 0.93 s to 0.004 s. The old cost curve made mesh-scale shapes effectively unusable (an STL lands one face per facet, so a 442k-triangle scan is ~1.3M edges), which is what forced OCCTMCP to route around the bridge in v1.13.0 (OCCTMCP#75/#77).

The bridge now discretises every edge in one pass, building the edge map once:

  • New C API: OCCTShapeComputeAllEdgePolylines(shape, deflection, maxPointsPerEdge) returns an OCCTEdgePolylinesRef handle, read via OCCTEdgePolylinesGetEdgeCount / …GetPointCount / …CopyPoints and freed with OCCTEdgePolylinesRelease. Edge ordering matches OCCTShapeGetTotalEdgeCount / OCCTShapeGetEdgePolyline; failed/degenerate edges are retained as 0-point entries so indices stay aligned with the shape’s edge indices.
  • The pcurve fallback’s edge→face ancestor map is now also built at most once per call, instead of once per edge that needs it.

No Swift API change. allEdgePolylines’ signature, ordering and skip-on-failure behaviour are unchanged, output is byte-identical to the old per-index path (covered by a parity test over box and cylinder), just dramatically faster. edgePolyline(at:) is unchanged and still rebuilds the map per call; it is now documented as one-off-lookup only. Bumped MINOR per the cohort SemVer policy: new C surface, additive.

This fixes the allEdgePolylines hot path only, the ~24 other per-index accessors (edge(at:), face/vertex variants) still rebuild their maps per call. Caching the map on OCCTShape with mutation-invalidation is the structural fix, left open on #275.

v1.8.8 (July 2026), feat: close the face-analysis tail (#266 follow-up, 6 ops)

Wraps the five low-value leftovers from the post-v1.8.7 face-gap re-audit, the face surface is now complete:

  • BRepLProp_SLProps V tangent (Shape.faceLPropTangentV), the tangent plane is now two-sided (was TangentU-only).
  • BRepGProp_Face integration internals (Shape): faceIntegrationKnotsV, faceSurfaceIntegration (precision-driven Gauss order + U/V subinterval counts), faceBoundaryIntegration(edgeIndex:) (edge-loaded boundary order/subs/knots).
  • ShapeFix_Face tolerance clamps (FaceFixer): setMaxTolerance, setMinTolerance.

Swift-only; no xcframework change. BRepGProp_Face::GetTKnots remains deliberately unwrapped (needs a loaded boundary arc and is subsumed by faceBoundaryIntegration).

v1.8.7 (June 2026), feat: face healing & validation surface (#266 follow-up)

New APIs (~16 ops). Rounds out the face-analysis surface flagged by the coverage audit:

  • ShapeFix_Face per-pass control (FaceFixer): setMode(_:_:) toggles any of the 11 healing passes (wire, orientation, addNaturalBound, missingSeam, smallAreaWire, removeSmallAreaFace, intersectingWires, loopWires, splitFace, autoCorrectPrecision, periodicDegenerated) before perform(); plus fixIntersectingWires(), fixPeriodicDegenerated(), fixWiresTwoCoincEdges(), fixLoopWire(), result (Face or Shell), and status(_:). Previously perform() ran with hardcoded defaults, e.g. no way to turn off the natural-bound pass that can balloon a trimmed face.
  • BRepCheck_Face per-wire diagnostics (Shape): checkFaceIntersectingWires, checkFaceWireImbrication, checkFaceWireOrientation, the specific BRepCheck_Status per check.
  • ShapeAnalysis_Surface extras (Surface): uvFromIso, singularity(_:) (full pole/iso detail), projectDegenerated, and domain-restricted projectPoint(_:uDomain:vDomain:).
  • BRepGProp_Face (Shape): faceIntegrationOrders, faceIntegrationKnotsU().

Swift-only; no xcframework change. The audit’s “rebound a face on its own surface” and “3D point classifier” candidates were verified already wrapped (faceAddHole and OCCTClassifyPointOnFace) and not duplicated.

v1.8.6 (June 2026), feat: face-from-surface with interior holes (#266)

New API. Shape.face(from: surface, outer: Wire, innerWires: [Wire]) builds a single trimmed face that has interior openings (windows / cutouts), a parametric surface trimmed by an outer boundary with N inner-wire holes. Wraps BRepBuilderAPI_MakeFace(surface, outer) + .Add(hole) per hole + ShapeFix_Face to project pcurves; hole winding is normalized automatically (tries holes reversed, falls back to as-given, returns the valid build). Until now every face-from-surface builder took a single outer loop, so a panel with holes couldn’t be one trimmed face.

Motivating case: OCCTReconstruct carbody side-panel surfacing, a fitted B-spline panel with window/door cutouts now surfaces cleanly instead of the surface ballooning over the windows (SecondMouseAU/OCCTReconstruct #133). Swift-only; no xcframework change.

v1.8.5 (June 2026), chore: slim xcframework to the core slices (≈57% smaller download)

Packaging only, identical kernel/source to v1.8.4. The shipped OCCT.xcframework now contains just the slices the ecosystem actually builds against, macOS arm64, iOS arm64, iOS-arm64-simulator , dropping the visionOS and tvOS device/simulator slices. Result: download 344 MB → ~149 MB, extracted ~1.3 GB → ~594 MB. Each shipped slice keeps its own Headers/ (SwiftPM auto-exposes per-slice headers to the C++ bridge, they cannot be de-duplicated to a single copy without breaking remote/URL consumers), so the header reduction comes from shipping 3 slices instead of 7.

Need visionOS / tvOS? Rebuild the full set with BUILD_ALL_PLATFORMS=1 Scripts/build-occt.sh (the package still declares those platforms). The build script defaults to the 3 core slices.

No API or behaviour change; the #263 ShapeFix kernel patch from v1.8.4 is retained.

v1.8.4 (June 2026), fix: OCCT kernel patch for ShapeFix_Face heap corruption (#263)

Binary release. Rebuilds OCCT.xcframework carrying a one-function OCCT source patch (Scripts/patches/0001-ShapeFix_Face-guard-non-face-context-replacement-263.patch) that fixes the upstream crash behind #263 at the kernel level.

ShapeFix_Face::Perform cast Context()->Apply(myFace) to TopoDS_Face without a type check; when an earlier fix in the shared ShapeBuild_ReShape context had replaced the face with a compound (a self-intersecting face split into several faces), the cast built an invalid face handle over a compound TShape and corrupted the heap (ShapeFix_Face::FixOrientation → BRep_Tool::Curve → BRep_TEdge::EmptyCopy, SIGSEGV/SIGBUS). The patch guards the entry of Perform: if the applied shape is not a face, return, the replacement is already recorded in the context. Submitted upstream as Open-Cascade-SAS/OCCT#1323 (CI green) and will be dropped from Scripts/patches/ once it ships in an OCCT release.

With this binary, a self-intersecting prism now heals to a valid solid instead of crashing; the v1.8.3 in-wrapper occtHasSelfIntersectingWire guard remains as defence-in-depth. xcframework rebuilt, remote SPM consumers get the new binary via the bumped Package.swift URL + checksum.

v1.8.3 (June 2026), fix: guard prism/heal against self-intersecting profiles (#263)

Bug fix. A self-intersecting mesh-derived outline (BRepCheck SelfIntersectingWire) extruded into a prism and then healed by OCCT’s ShapeFix_Shape corrupts the heap and aborts the process with an uncatchable OS signal, the exact #263 fault (ShapeFix_Face::FixOrientation → BRep_Tool::Curve → BRep_TEdge::EmptyCopy). Isolated to a pure-OCCT reproducer (a 4-point “bowtie” face: extrude succeeds, healing the prism crashes 3/3) and reported upstream as Open-Cascade-SAS/OCCT#1322.

OCC_CATCH_SIGNALS is inert in this build, so the signal cannot be caught once raised. The fix prevents it: a cheap, no-meshing BRepCheck_Analyzer guard (occtHasSelfIntersectingWire) makes Shape.extrude / Shape.extruded(by:) / Shape.healed() return nil for a self-intersecting profile instead of building/healing the crashing solid (such a profile can never form a valid extruded solid). Consumers (e.g. OCCTReconstruct reify) now degrade gracefully instead of aborting.

Swift-only, no xcframework rebuild. New SelfIntersectingProfileGuard263 suite + full Modeling (409) and ShapeHealing (208) domains green. Closes #263.

v1.8.2 (June 2026), feat: smooth multi-start threadedShaft direct build (#257)

Feature. Multi-start threads (threadedShaft(starts: N), N > 1) now build via the smooth, boolean-free direct path instead of falling to the faceted boolean cut (which produced disconnected notches, #254). The single-start cam-slice loft is generalised to N teeth tiling the turn at lead = N·pitch, giving a continuous interleaved multi-helix, a low-face-count, BRepCheck-valid solid with the crest exactly at the nominal major radius. Partial-length multi-start (thread + plain shank) closes via per-start shoulder faces; full-length is the lofted solid directly.

Covers the piecewise-linear forms the direct build already supports (ISO/Unified, trapezoidal/ACME, square, buttress). Rounded (knuckle / rounded Whitworth), tapered (NPT/BSPT), and non-cylinder targets still use the cut path.

Key detail: the loft samples per pitch (not per lead), sampling per turn under-samples each tooth at N > 1 and the ruled:false loft balloons the crest radially past nominal. Swift-only, no xcframework rebuild. Verified: 2-/3-start crest = nominal by mesh vertices; start count = N.

v1.8.1 (June 2026), fix: single-start threadedShaft is always a smooth helix; deprecate .boolean (#254)

Fix. threadedShaft(build: .boolean) produced a faceted, disconnected thread, a helical scatter of rectangular notches rather than a continuous groove, because it forced the screw-loft boolean cut path, whose tightly-wound helical cutter is the classic OCCT BOP failure (cf. #213/#225). The solid was isValid with roughly the right volume, so only rendering exposed it.

.boolean only ever existed to clamp a supposed crest “overshoot” from #222, but #232 established that overshoot is a Bnd_Box control-hull artifact (verified here: the direct build’s crest measures exactly nominal by both boundingBoxOptimal() and mesh vertices, while .bounds over-reads +14–21%). With no remaining reason to prefer it, single-start coaxial-cylinder threads now take the smooth, boolean-free direct build (#213) for every build mode, and ThreadBuild.boolean is deprecated (now treated as .auto). Use .auto or .direct.

.auto / .direct single-start behaviour is unchanged (they already built direct). Swift-only change, no xcframework rebuild.

Known limitation: multi-start threads (starts > 1) and non-cylinder targets still use the faceted cut path, which can come out as disconnected notches, a smooth multi-start/internal direct build is a tracked gap.

v1.8.0 (June 2026), feat: Exporter.writeBREP(allowInvalid:)

Feature (additive). Exporter.writeBREP (and the Shape.writeBREP instance wrapper) gain an allowInvalid: Bool = false parameter. When true, the shape.isValid pre-check is skipped and the shape is serialized as-is. BREP is OCCT’s lossless native format and BRepTools::Write does not require a topologically valid shape, so an in-progress reconstruction, a compound of loose analytic faces, possibly with a few invalid faces, can be persisted and later reloaded for measurement / diagnostics (Shape.loadBREP already does not gate on validity). Default false preserves the existing validity gate, matching the other exporters. Enables OCCTMCP #41 (measure an imperfect reconstruction without forcing it through the validity gate). No xcframework change.

v1.7.11 (June 2026), fix: fromPointGrid degree clamp prevents a BRepMesh hang (#244)

Bug fix. Surface.fromPointGrid now clamps the B-spline fit degree to min(uCount, vCount) − 1. Passing a degMax higher than the grid supports (e.g. the default degMax: 8 on a 7×7 grid) over-parameterised the fit, a degree-8 surface from only 7 samples/direction oscillates (Runge phenomenon) and can self-overlap in 3D. The face was topologically valid (BRepCheck passes) but geometrically rippling, so BRepMesh’s adaptive refinement never converged, an in-process, uninterruptible hang (the OCCTReconstruct blocker). Clamping the degree keeps the fit well-posed; the 7×7 case now meshes in ~40 ms.

Prevention is the fix: a watchdog-based bounded mesh was prototyped and rejected. BRepMesh does not poll UserBreak during heavy meshing (verified: a fine sphere ran ~13 min / 5 GB ignoring a 0.01s deadline), so an in-process time bound can’t be made both reliable and safe. No xcframework change.

v1.7.10 (June 2026), crash fix: degenerate hole wires (#234); housekeeping (#178, #210)

Bug fix + docs.

  • #234, faceAddHole rejects degenerate hole wires. A 2-vertex / zero-area / collinear hole wire was accepted, producing an invalid face whose extruded prism SIGSEGV’d OCCT’s ShapeFix (healed()), an uncatchable OS signal. OCCTMakeFaceAddHole now returns nil for a hole wire with < 3 distinct vertices or all-collinear points, breaking the crash chain at the source. (The general “healed() never crashes on any invalid input” can’t be defended in-process, the fault is inside OCCT’s uncatchable ShapeFix.)
  • #178, loft polar-iterator fix is upstream. The BRepFill_CompatibleWires guard (#176) shipped in OCCT 8.0.0p1; the carried Scripts/patches/0001-* was dropped. Corrected the stale CLAUDE.md note + #176 regression test comment (the test passes against the unpatched p1 xcframework).
  • #210, context7. Runnable-snippet doc comments on the core ops (primitives + booleans) and a CLAUDE.md doc-standards rule (“document with a runnable Swift snippet so context7 indexes it”). The Swift API is now indexed and queryable on context7 (/gsdali/occtswift).

No new operations; no xcframework change.

v1.7.9 (June 2026), face from surface bounded by a wire / UV polygon (#233)

Additive, source-compatible. Trim a curved analytic surface (cylinder / cone / sphere / B-spline) to a non-rectangular region, instead of only a rectangular UV patch.

  • Surface.toFace(uvBoundary: [SIMD2<Double>]): a closed UV-space boundary polygon becomes 2D edges with pcurves on the surface → BRepBuilderAPI_MakeFace(surface, wire) + BuildCurves3d.
  • Shape.face(from: Surface, boundary: Wire): a 3D boundary wire: exact MakeFace + ShapeFix_Face when the wire lies on the surface, else a fallback that projects the wire’s ordered points to UV and trims by that polygon (handles sampled boundary polylines; a seam-crossing boundary isn’t handled by the fallback).

Bridge: OCCTShapeCreateFaceFromSurfaceUVPolygon, OCCTShapeCreateFaceFromSurfaceWire. Surfaces 86→88, total 4,290 operations. No xcframework change.

Also lands the #232 investigation (doc + tests, no behavior change): Shape.bounds over-reports for B-spline/faceted geometry (control-hull artifact), threaded solids are bounded exactly to length/depth; Issue232BoundsTests asserts the true (mesh-vertex) extent.

v1.7.8 (June 2026), cookbook: surfaces from points + working with meshes (#230, #231)

Documentation only, no code, API, or xcframework change. Two new cookbook pages; snippets compile-checked against the shipped API.

  • Surfaces from Points (#230), fit a B-spline Surface through 3D points: a regular grid via Surface.fromPointGrid (GeomAPI_PointsToBSplineSurface), a scattered cloud via Surface.plateThrough (GeomPlate), and deform-an-existing-surface-to-targets via nlPlateDeformed (NLPlate). With a which-to-use table (vs. Surface.gordon for curve networks).
  • Working with Meshes (#231), operating on the Mesh value type (distinct from Meshing & Export): build from vertex/index arrays, inspect, triangle ↔ B-Rep face picking (trianglesWithFaces), mesh-level booleans, toShape, and SceneKit / RealityKit / Metal interop.

v1.7.7 (June 2026), cookbook: Gordon surfaces (#229)

Documentation only, no code, API, or xcframework change. New cookbook page on Gordon surfaces, skinning a surface through a network of crossing profile + guide curves via Surface.gordon / Surface.gordonReport (GeomFill_Gordon). Covers the grid-closure requirement, build diagnostics (GordonResultStatus, allowApproximateFallback), the lower-level networkSurface (GeomFill_NetworkSurface) and its knot-alignment caveat, and a Gordon-vs-loft-vs-fill decision table. Snippets compile-checked against the shipped API; figure rendered from the same network the page shows.

v1.7.6 (June 2026), cookbook complete: healing, meshing, XCAF, topology (#210, #228)

Documentation only, no code, API, or xcframework change. Adds the final four cookbook areas, completing the issue #210 area list (the Swift-API counterpart to OCCT’s own user guides). Every snippet was compile- and run-checked against the shipped API.

  • Healing & Validity: isValid / isValidSolid / isSelfIntersecting, analyze, signedVolume + orientedForward, the repair ops (healed / fixed / unified / upgraded), sewing, and free-boundary gap finding/closing.
  • Meshing & Export: mesh(linearDeflection:) + MeshParameters, the Mesh type, mesh.toShape, a deflection table, and STL / OBJ / PLY / STEP / IGES / BREP / glTF export + import with a round-trip.
  • XCAF Assemblies: Document trees, components & instancing, names / colors / materials, and structured STEP / GLB round-trip (with a two-colour assembly figure).
  • Topology Graph: TopologyGraph node counts, adjacency / shared edges / sameDomainFaces, durable GraphUIDs (vs ephemeral NodeRef), and history tracking through operations.

v1.7.5 (June 2026), threadedRod from a custom profile + helical-sweeps cookbook (#225)

Additive, source-compatible. New Shape.threadedRod(customProfile:nominalDiameter:pitch:cutDepth:length:…) builds a smooth worm/screw from a custom radial tooth profile directly, composing the helicoid with the core by sewing, with no boolean, yielding a BRepCheck-valid, analytic solid (a handful of B-spline faces → a sub-MB STEP).

This addresses #225: helicalSweep + union/subtract against a coaxial cylinder produces an invalid (union) or collapsed-to-zero (subtract) result that no fuzzy value or heal pass recovers, OCCT’s BOP can’t resolve the coincident/tangent helicoid faces (consistent with #213, #181). The boolean compose path was never the way; the direct build is. The custom-profile direct build already existed under threadedShaft(spec:) with a ThreadSpec(customProfile:), threadedRod makes it a discoverable one-liner and never silently falls back to an invalid boolean (returns nil instead).

  • ThreadProfile.supportsSmoothRodBuild: public predicate (real crest flat, ≤ 2 flanks) for whether a custom profile can take the direct build.
  • Shape.helicalSweep(…) doc now warns against the boolean-compose anti-pattern and points to threadedRod.
  • Cookbook: Helical Sweeps: new page (helicalSweep helicoids vs. threadedRod worms, and why the boolean compose fails), with rendered figures.

v1.7.4 (June 2026), docs: cookbook lofting & sweeps, context7 onboarding

Documentation only, no code, API, or xcframework change.

  • Cookbook: Lofting & Sweeps (#226), new example-rich page covering extrude, revolve, sweep-along-path, loft (square→round, ruled vs smooth, point-capped cones), and multi-section pipe shells, with a “loft vs multi-section sweep, which?” decision section. Every snippet is compile- and run-checked against the shipped API; four figures (pipe elbow, frustum, cone, vase) rendered headlessly as PNG posters + interactive <model-viewer> GLB models.
  • context7 onboarding (#224), added context7.json scoping context7’s crawl to the Swift API (docs/, Sources/OCCTSwift) with usage rules, so the Swift surface becomes queryable on context7 (issue #210).
  • WebAssembly feasibility plan (#223), docs/wasm-feasibility.md: analysis + phased plan for reusing the OCCTSwift API in a SwiftWasm app (deferred; the wasi-sdk-vs-Emscripten ABI split is the central obstacle).

v1.7.3 (June 2026), smooth fine-pitch internal threads (#219)

Bug fix. threadedHole on a fine-pitch internal thread (e.g. 3/8-16 UNC, M10×1.5) came out faceted. The ruled:false smooth helical cutter self-intersects in a degenerate band around the default ~14 sections/turn, the axial step per section is far smaller than the groove’s axial half-width, so consecutive sections overlap many-deep and the lofted B-spline pinches, making the boolean a no-op that silently fell back to the faceted cutter. The cut path now builds the smooth internal cutter at a denser, escalating section count (24→36/turn) and takes the first sound cut; the faceted cutter remains the fallback for genuinely awkward composite bodies. Fine-pitch internal threads now cut smooth (the wing-nut cookbook bore drops from ~247 faces to ~15). No API change.

v1.7.2 (June 2026), thread envelope fix (#222)

Additive, source-compatible. Shape.threadedShaft(…) gains a build: ThreadBuild = .auto parameter. At coarse pitch / wide crest flats the smooth direct rod build (#213) bows the crest past the nominal major radius (+14–21% measured: M12×1.75 → r 6.85 vs 6.0; Tr12×3 → 7.28), which oversizes headless single-start parts (lead screws, studs, worms). build: .boolean forces the boolean cut path, cutter subtracted from a cylinder of radius exactly nominalDiameter / 2, so the crest is clamped in-envelope (≤ nominal, ~1% tessellation margin). .auto (default) and .direct keep the original smooth build. No existing call sites change.

v1.7.1 (June 2026), p1 follow-ups + xcframework header hygiene

Additive + a packaging fix. New p1 operations and a corrected xcframework (no stale headers).

New operations

  • BRepGraph durable identity: TopologyGraph UID/RefUID/ItemUID accessors (uid(ofNodeKind:index:), node(forUID:), contains(uid:), ref/item variants, generation) over BRepGraph::UIDsView, giving persist-safe identifiers (the migration note’s UID/RefUID/ItemUID, vs the non-durable NodeId/RefId).
  • Surface.networkSurface(profiles:guides:tolerance:): wraps the new GeomFill_NetworkSurface low-level Gordon builder, with a NetworkSurfaceStatus.
  • Surface.gordonReport(…): exposes GeomFill_Gordon’s new Status()/IsApproximate() and the ExactOnly/approximate-fallback ApproximationMode (GordonResult + GordonResultStatus).
  • Polygon2D.copy(), PolygonOnTriangulation.copy()/setNodes()/setParameters(): the new Poly_* copy/mutator APIs.
  • BRepGraph reads, now real: faceSameDomain(of:) (derived from edge-incidence + surface equality), face/edge adjacency & shared-edges (derived from first-class reverse relations), faceIsNaturalRestriction (Tool::Face::NbWires == 0).
  • BRepGraph vertex-supplement: faceAddVertex/edgeAddInternalVertex/faceRemoveVertex/faceNbVertexRefs now back onto the BRepGraph_LayerTopoSupplement layer (uid/shape-based; the v1.7.0 stubs were no-ops).

Packaging fix, stale headers removed from the xcframework

build-occt.sh reused the CMake install prefix across builds; cmake --install adds headers but never deletes removed ones, so 18 OCCT 8.0.0-GA headers that p1 removed/renamed (e.g. Approx_BSplineApproxInterp.hxx, BRepGraph_Builder/History/RepId/MeshCache/LayerRegularity.hxx, GeomFill_GordonBuilder.hxx) leaked into the v1.7.0 framework, where they masqueraded as current API (their symbols were never in the library). The build script now wipes the install prefixes each run, and the v1.7.1 xcframework contains only real p1 headers. (Functionally harmless in v1.7.0, the phantom headers had no symbols, but misleading.)

Note on edge regularity/continuity: one of those phantom headers (BRepGraph_LayerRegularity) made it look like a graph-level regularity API existed in p1. It does not (p1 ships BRepGraph_LayerParametric instead); TopologyGraph.edgeMaxContinuity/setEdgeRegularity remain no-ops. Use Shape.maxContinuity (BRep_Tool::MaxContinuity) for edge continuity.

v1.7.0 (June 2026), OCCT 8.0.0p1 upgrade; BRepGraph realigned to its redesigned model

MINOR, dependency upgrade with API-behaviour changes confined to the BRepGraph domain. OCCT shipped 8.0.0p1 as a hot patch on top of 8.0.0. OCCTSwift now pins it (V8_0_0_p1). Everything outside BRepGraph is a transparent upgrade; BRepGraph itself was comprehensively redesigned upstream and our wrapper has been realigned to the new model rather than shimmed back to the old one.

Upstream fix landed

Our BRepFill_CompatibleWires::SameNumberByPolarMethod() polar-iterator guard (OCCTSwift #176, the loft/ThruSections SIGSEGV on mismatched closed profiles) shipped in 8.0.0p1. The source patch we carried (Scripts/patches/0001-…) is therefore removed; build-occt.sh pins OCCT_RC="p1".

Removed/changed OCCT classes migrated (non-BRepGraph)

  • Approx_BSplineApproxInterp (removed) → BSplineApproxInterp is reimplemented on GeomAPI_PointsToBSpline (the documented replacement). The C/Swift ABI is unchanged, but nbControlPoints is now advisory (the approximator chooses the pole count to meet tolerance) and interpolatePoint(_:withKink:) is a no-op (no per-point exact-interpolation/kink control in the replacement). maxError is computed by projecting the inputs onto the fitted curve.
  • GeomFill_Gordon (reworked): API remained source-compatible; no wrapper change.
  • BRepGraph_RepId moved to the BRepGraphInc subpackage (header BRepGraphInc_RepId.hxx).

p1 crash fixes (OS-signal null-derefs that catch(...) cannot trap)

  • Extrema_ExtElCS (line ∥ cylinder axis): infinite/degenerate extrema crash. ExtremaElCS.lineToCylinder now returns 0 when the line is parallel to the cylinder axis.
  • ShapeUpgrade_WireDivide / ShapeFix_ComposeShell: p1 made the ShapeBuild_ReShape context mandatory; Perform() null-derefs without one. Both bridges now set a context (plus WireDivide guards a wire whose edges have no pcurve on the target face).
  • Wire.rectangle with sub-Precision::Confusion() dimensions made degenerate edges that crashed downstream; such dimensions are now rejected (returns nil).

BRepGraph realigned to the 8.0.x model

BRepGraph is OCCT’s explicit graph-oriented topology model (see Open-Cascade-SAS/OCCT discussion #1291). 8.0.0p1 reworked it around nine separated concerns, topology definitions vs references/usages, geometry reps, mesh reps, products/occurrences, persistent UIDs, metadata layers, modification stamps (version counters, not booleans), and self-invalidating caches. The wrapper was rewritten to that model. Upstream notes the interface “will change slightly in 8.1 and in development versions after 8.0,” so expect further churn here.

Concretely:

  • Shape ingestion: BRepGraph_Builder removed → BRepGraph::ShapesView::Add().
  • History: BRepGraph::History() removed → the registered BRepGraph_LayerHistory layer (LayerRegistry().FindLayer<>() / .Ensure<>()); records are Events.
  • Topology queries moved across views: counts to Topo().Geometry().NbFaceSurfaces() etc.; IsBoundary/IsManifold/FindCoEdgeId to BRepGraph_Tool::Edge; SameParameter/SameRange to BRepGraph_Tool::CoEdge (per-coedge, derived). Edge→faces / vertex→edges are first-class reverse relations (FacesOf, VertexOps::Edges); face/edge adjacency and shared-edges are derived from them (no direct adjacency call survived, but the data does).
  • Mesh + geometry representations are handle-based: integer “rep ids” are gone. The wrapper keeps its rep-id Swift API working via a per-graph handle registry that backs the new Mesh().Editor().Faces().SetCachedTriangulation(face, handle) / persistent-rep setters. Mesh cache inspection reads Mesh().Cache().*.Entry() (each holds a single handle + a MeshGeneration stamp).
  • Edge start/end vertex now resolves a VertexRefId (a per-edge use) to its vertex definition.
  • Root products require explicit AppendDocumentRoot() after creation.
Deliberately-removed concepts (now no-ops or derived-getter-only, by design, not breakage)

These reflect BRepGraph’s intent; the capability lives elsewhere in the new model:

  • Flags are derived from geometry, not stored → SameParameter/SameRange/Degenerated/IsClosed setters are no-ops; the getters return the live derived value.
  • Regularity/ownership are controlled layers, not inline flags → the old SetEdgeRegularity / EdgeMaxContinuity inline path is gone.
  • Natural-bound faces are normalized away (explicit topology is required below a bounded face) → …NaturalRestriction get/set no longer apply.
  • Locations live on assembly references (occurrence/child), not per-subshape → the per-vertex/edge/ wire/face/shell/solid/coedge …RefLocalLocation setters are gone; occurrence/child placement setters remain.
  • Coedges are first-class (a coedge is the edge-on-face use, carrying orientation/pcurve/seam) → the coedge-as-separate-reference setters are gone; NbCoEdgeRefs reports the coedge count.
  • Vertices are references with reverse relations → face/edge vertex add/remove mutators are gone (population builds them); query via the reverse relations instead.

Test/behaviour notes

  • GC_MakeHyperbola (3-point) is stricter in p1: a collinear S2 (zero minor radius) is rejected; the test now uses a valid off-axis S2.
  • Run the suite with swift test --no-parallel, the pre-existing non-deterministic NCollection arm64 race makes the parallel run flaky (unrelated to p1).

v1.6.3 (June 2026), buttress trued to DIN 513; Whitworth & knuckle finished

PATCH, geometry corrections, non-breaking. The last two medium-confidence thread forms are trued to their standards:

  • .buttress → DIN 513 (German Sägengewinde): asymmetric 3° load / 30° clearance flanks (33° total) at depth 0.86777·P (so the bolt core d3 = d − 2·0.86777·P, verified against the DIN 513 table, e.g. S 10 × 2 → d3 = 6.528). Previously it used a reconstructed ANSI 7°/45° profile at 0.66271·P, which matched no German standard.
  • .whitworth / .bspParallel confirmed at the correct 55° / 0.640327·P and kept as the standard BS 84 flat-truncation (crest = root flat = P/6). A fully rounded crest makes the deep tooth’s ruled:false loft spike past the nominal radius (a thin outward flap, OCCTSwift #213), so the truncation is the form that builds smooth and dimensionally exact.
  • .knuckle now routes through the faceted cut path for the external build. The previous rounded-crest direct loft was both slow (~28 s) and bulged ~6% past the nominal crest; the cut path keeps the crest exactly at the nominal radius and builds in ~1 s. (Rounded profiles, those with more than two straight flanks, are now detected and sent to the cut path generally.)

Buttress cookbook figure re-rendered with the DIN 513 profile.

v1.6.2 (June 2026), knuckle thread trued to DIN 405

PATCH, geometry correction, non-breaking. The .knuckle form now matches DIN 405: depth 0.55·P (so the bolt minor d3 = d − 1.1·P, verified against the standard dimension table, e.g. Rd 8 × 1/10″ → d3 = 5.460) and a proper 30°-included (15° per side) flank with circular-arc rounded crest and root (the rounding radius is solved for flank tangency). Previously it used a cosine profile at 0.5·P (≈60°-included flanks). A small crest/root land is retained so the smooth direct build still applies.

v1.6.1 (June 2026), smooth internal threads

PATCH, quality improvement, non-breaking. threadedHole now produces smooth internal threads instead of faceted ones. An interior helix is cut into a thick wall (not a thin shaft), so OCCT’s boolean subtracts a smooth (ruled=false) helical cutter robustly, verified valid across all orientations. (The external fallback is unchanged: subtracting a smooth cutter from a thin external cylinder is the unreliable case from #213, so non-cylinder/tapered external cuts stay faceted.) Cookbook nut / wing-nut / lead-screw figures re-rendered with the smooth bore threads.

v1.6.0 (June 2026), thread forms + custom profiles

MINOR, additive, non-breaking (existing ThreadSpec/threadedShaft calls are unchanged).

The thread feature now covers the common standard forms beyond the 60° V, and can thread a cylinder with any cross-section:

  • New ThreadForm cases: .whitworth / .bspParallel (55°), .acme (29°) / .trapezoidal (metric Tr, 30°), .square, .buttress (7°/45°), .knuckle (rounded), .nptTapered / .bsptTapered (60°/55° on a 1:16 taper), and .custom. (UNF/UNC, metric-fine, and SAE remain pitch/standards variants of the existing 60° forms, no new cases needed.)
  • ThreadProfile: a public, Codable normalized tooth cross-section (vertices of axial 0…1 × depth 0 = crest … 1 = root). ThreadSpec(customProfile:nominalDiameter:pitch:cutDepth:) threads a cylinder with an arbitrary shape. Built-in form profiles are exposed too (.iso60V(), .acme29, .square, …).
  • Geometry is now form-dependent: ThreadSpec.cutDepth / profile / taperRatio switch on the form. ISO/Unified compute identically to before (5H/8, P/8 crest, P/4 root, 30° flanks).
  • All forms work external and internal: external cylinders use the smooth, BRepCheck-valid direct build (#213), a handful of faces; internal threads (threadedHole), non-cylinder targets, and the tapered pipe forms use the robust faceted cut path. The OCCT bridge is unchanged (a thin wrapper); all new geometry is composed in Swift.
  • Parser recognises Tr40x7[LH], 1.5-4 ACME, G1/2 (BSP), R…/Rc… (BSPT), W1/2 / 1/2 BSW (Whitworth), and 1/2-14 NPT, alongside the existing metric/Unified designations.

Cookbook: the Threads page gains a forms gallery and a custom-profile example.

v1.5.3 (June 2026), smooth, valid ISO V-threads built without booleans (closes #213)

PATCH, additive, non-breaking (same threadedShaft API; smoother/valid result).

Shape.threadedShaft(form: .iso68) produced a near-square groove (~6.6° flanks) instead of a true 60° V (30° flanks): the cutter’s flank offsets used the crest/root truncation flats and omitted the cutDepth·tan(30°) flank term. Fixing the profile, however, exposed a deeper limit. OCCT’s boolean engine cannot reliably subtract a smooth helical V-thread cutter from a cylinder (it under-cuts / no-ops on ~half of all orientations, unfixable by bleed / fuzzy / cone / extend; only the faceted screw-loft is robust, because its planar facets cross the shaft transversally).

So threadedShaft now builds the threaded rod directly, with no boolean, when the target is a plain cylinder coaxial with the axis (the common case):

  • The thread region is a ruled=false ThruSections loft of the thread’s true cross-section (“cam”: root arc → flank spiral → crest arc → flank spiral) at z-slices rotated by the helix, one BSpline face per cam edge (~9 faces, not hundreds of facets), flat caps, solid-to-axis.
  • Any unthreaded margin is closed by pure sewing, a single-loop shoulder face + plain cylinder + end disk, not a fuse (a fuse is robust here but 6–71 s; sewing is ~0.3 s).

Because the kernel’s BOP is never invoked, the result is orientation-robust AND BRepCheck-valid where the old cut path was faceted or failed. The boolean cut path remains the fallback for non-cylinder targets, internal threads (threadedHole), and multi-start. The whole construction is composed in Swift from already-wrapped primitives (Shape.loft(ruled:), Wire.arc/.interpolate, Shape.face(from:), Shape.sew, Shape.solidFromShell), so the OCCT bridge stays a thin wrapper, no thread-specific bridge code.

Note: the smooth thread is a BSpline solid, so its default Bnd_Box is the control-pole hull and overshoots the true surface by ~13% (a pole artifact, not a bulge); use boundingBoxOptimal() for the real extent (the crest sits exactly at the nominal radius).

v1.5.2 (June 2026), reconstruction wrapping gaps: outer shell, mesh quality flag, wire arc-length adaptor (closes #211)

PATCH, additive, non-breaking. Closes the confirmed gaps from the mesh→CAD reconstruction coverage audit (#211):

  • Shape.outerShell → Shape? (BRepClass3d::OuterShell), the outer body shell of a solid, distinguishing it from internal void shells. nil for non-solids. Decomposes a part into outer-body + cavities.
  • MeshParameters.allowQualityDecrease (IMeshTools_Parameters::AllowQualityDecrease, default false), the one missing mesh knob. Lets a re-mesh at a different deflection actually replace an existing finer triangulation (e.g. a deviation re-measure), instead of OCCT silently keeping the coarser/finer mesh.
  • WireCurve (BRepAdaptor_CompCurve), treats a multi-edge wire as one arc-length curve: length, point(atAbscissa:) / tangent(atAbscissa:) (walk across edge boundaries), points(count:) / points(spacing:) for even arc-length sampling (GCPnts_UniformAbscissa), plus native parameterRange / point(atParameter:) / tangent(atParameter:). Replaces ad-hoc per-edge sampling when placing sections along a measured wire.
  • EdgeCurve (BRepAdaptor_Curve), the single-edge sibling of WireCurve: adds the arc-length side (length, point(atAbscissa:), points(count:/spacing:)) that Edge’s native point(at parameter:) lacked.
  • Shape.innerShells: the void/cavity shells of a solid (every shell except outerShell); pairs with outerShell to fully decompose a part into outer body + cavities.

Also from #211, verified and not needing changes: Shape.minDistance(to:) -> Double? already exists; and a “scattered point-cloud” GeomAPI_PointsToBSplineSurface fit is not an OCCT capability, every constructor is grid-based (Array2); a cloud fit means resampling to a grid (already wrapped via Surface.fromPointGrid) or GeomPlate / BRepOffsetAPI_MakeFilling (already wrapped). Source-only (no xcframework change).

v1.5.1 (June 2026), Shape.isSelfIntersecting(timeout:), bounded self-intersection check (closes #208)

PATCH, additive, non-breaking. Follow-up to #206. isValidSolid is a topology-level check (BRepCheck_Analyzer) that misses global self-intersection, a self-intersecting B-spline solid from loft(ruled: false) can report isValidSolid == true yet poison downstream booleans. New:

func isSelfIntersecting(timeout: Double = 30) -> Bool?   // true / false / nil(=indeterminate)

Backed by BOPAlgo_ArgumentAnalyzer’s self-interference test (stop-on-first-faulty), wrapped in the same wall-clock watchdog as the #206 booleans so it can’t hang: returns true (self-intersects), false (clean), or nil if it couldn’t finish within timeout (indeterminate, treat as “unknown”, not “clean”). The test is expensive (seconds on B-spline solids), so it’s opt-in. Verified on the #206 operands: nurbs_env → true (the actual culprit), and the docs give the validate-at-source recipe (orientedForward() + isSelfIntersecting() == false).

Why not a cheap volume/isValidSolid guard (the issue’s other options): investigation showed the reported env operand passes BRepCheck, sits within its bounding box, and has positive volume , nothing cheap flags it. And a volume <= 0 reject would false-positive on legitimately reversed-orientation solids (a known, orientedForward()-fixable case), so it isn’t sound. isValidSolid’s doc now spells out the topology-vs-self-intersection distinction. Source-only.

v1.5.0 (June 2026), boolean ops are time-bounded; never hang indefinitely (closes #206)

MINOR, additive param + a default-behavior change. Shape.union / subtracting / intersection could hang indefinitely on a self-intersecting / inside-out operand, e.g. a B-spline solid from loft(ruled: false) that reports isValidSolid == true yet poisons the boolean. BRepAlgoAPI_Cut on the reported operands spun for >5 min on a 66-face input.

The boolean ops now run under a wall-clock watchdog (OCCT’s Message_ProgressRange + UserBreak) and return nil at a deadline instead of spinning forever:

func union(_ other: Shape, fuzzyValue: Double = 0, glue: BooleanGlue = .off,
           timeout: Double = Shape.defaultBooleanTimeout) -> Shape?   // and subtracting / intersection
  • timeout: seconds; default Shape.defaultBooleanTimeout (120s). 0/negative = unbounded (the prior behavior). Verified to interrupt the real #206 operands (was an infinite hang → now nil).
  • Default-behavior change: a boolean that genuinely runs longer than 120s now returns nil instead of completing/blocking. Pathological hangs are bounded; raise timeout (or pass 0) for legitimately heavy booleans.

Why a timeout and not an operand pre-check: the cheap detectors don’t catch the reported env operand, BRepCheck_Analyzer reports it valid and its volume sits within its bounding box; only BOPAlgo_ArgumentAnalyzer flags it, and that itself ran >50s on the input. The watchdog is the only general, bounded guard. (The separate cav operand has negative volume, so a downstream volume > 0 && analyzeValidity(geometryChecks:) gate remains a useful cheap fast-fail and is still recommended.) Source-only (no xcframework change).

v1.4.7 (June 2026), boolean fuzzy value + glue options (closes #202)

PATCH, additive, non-breaking. Shape.union / subtracting / intersection now expose the two BRepAlgoAPI_BooleanOperation robustness levers OCCT provides for coincident / near-tangent faces, where the default boolean can silently under-subtract or inflate volume:

func union(_ other: Shape, fuzzyValue: Double = 0, glue: BooleanGlue = .off) -> Shape?
// same trailing parameters on subtracting(_:) and intersection(_:)
  • fuzzyValue → SetFuzzyValue (tolerance-based fuzzy boolean; 0 keeps OCCT’s default, negatives ignored).
  • glue → SetGlue, new Shape.BooleanGlue enum: .off (default), .shift (BOPAlgo_GlueShift), .full (BOPAlgo_GlueFull). Gluing hardens & speeds up unions/cuts of solids known to share coincident faces (e.g. consecutive analytic loft chunks, thin-wall shells).

Defaults reproduce prior behavior exactly. Implemented via a shared templated bridge driver (OCCTShapeUnionEx/SubtractEx/IntersectEx) over the common BRepAlgoAPI_BooleanOperation base. Source-only (no xcframework change).

v1.4.6 (June 2026), instanced-assembly STEP writer (closes #173)

PATCH, additive, non-breaking. New Exporter.writeSTEPAssembly(_ document: Document, to url:) writes an XCAF Document as a product-structured STEP assembly: each unique part label becomes one STEP product, referenced by its located component occurrences (NEXT_ASSEMBLY_USAGE_OCCURRENCE + each component’s TopLoc_Location). A part placed N times stores one MANIFOLD_SOLID_BREP, not N copies, file size scales with unique parts, and the result opens as an editable assembly in standard CAD viewers (AP214). Names/colors set on the document are preserved.

The underlying capability already existed (Document.writeSTEP transfers the XCAF doc via STEPCAFControl_Writer, and full rotation+translation placement landed in #174); this adds the named, documented, throwing convenience entry point #173 asked for, plus instancing + round-trip tests.

v1.4.5 (June 2026), mesh→shape weld tolerance is caller-tunable (#197)

PATCH, additive, non-breaking. Mesh.toShape() sewed its triangles into a shell at a hardcoded 1e-6 weld tolerance. That tolerance must scale with the mesh’s coordinate magnitude, too small for a large-coordinate (or imprecise, imported) mesh leaves shared edges unmerged and silently yields an open shell. It now takes weldTolerance: Double = 1e-6 (the default reproduces prior output); non-positive values return nil. From the #197 hardcoded-constant sweep, the audit (see issue) found this the one remaining genuine knob; the rest of the 1e-X literals are internal correctness epsilons left as-is.

v1.4.4 (June 2026), mesh deflection is caller-tunable on auto-meshing utilities (#197)

PATCH, additive, non-breaking. Several utility functions auto-triangulated their input at a hardcoded 0.1 mm deflection, leaving callers no control over fidelity/speed. Each now takes a deflection: Double = 0.1 parameter (the default reproduces prior output). First slice of the #197 hardcoded-constant sweep, the mesh deflection area:

  • Shape.writeSTLBinary(to:deflection:) / writeSTLAscii(to:deflection:), STL export resolution.
  • Shape.proximityFaces(with:tolerance:deflection:): proximity triangulation.
  • Shape.selfIntersectionPairs(tolerance:maxPairs:deflection:): self-intersection triangulation.
  • CoherentTriangulation.createFromMesh(_:deflection:).

(The primary STL path Exporter.writeSTL(shape:to:deflection:) already exposed this.) Source-only; remaining #197 areas, tolerances, sampling counts, tracked in the issue.

v1.4.3 (June 2026), fast 2D drawings of threaded solids via polyhedral HLR (closes #196)

PATCH, additive + guidance. The v1.4.1 smooth analytic thread helicoid is HLR-hostile under OCCT’s exact HLR (hlrEdges / HLRBRep_Algo): projecting its BSpline faces computes analytic helical silhouettes and blows up, a downstream 2D-drawing pipeline measured ~19× slower vs the v1.4.0 faceted thread.

The fix is not to change the solid. OCCT’s polyhedral HLR (hlrPolyEdges / HLRBRep_PolyAlgo, already wrapped) projects the shape’s triangulation, so it is fast on any surface, measured ~48× faster than exact HLR on an analytic M10 thread (337 ms vs 16.4 s, side view), while the one analytic solid stays smooth for STEP. Prefer hlrPolyEdges for 2D drawings of threaded / curved solids; reserve exact hlrEdges for analytically simple shapes.

hlrPolyEdges(direction:category:deflection:) now exposes the internal mesh deflection (mm, default 0.1) so drawing pipelines can trade fidelity (more, shorter edges) for speed. Non-breaking, the default reproduces prior output. (No GPU offload needed; the polyhedral CPU path already recovers the speed. The broader hardcoded-constant sweep this surfaced is tracked in #197.)

v1.4.2 (June 2026), long full-length threads return a usable solid, not nil (closes #193)

PATCH, regression fix. A long full-length thread (threadedShaft over tens of turns, e.g. an ISO 4017 M10×50 full-thread shank ≈ 49 turns) came back nil. No API change.

Cause. v1.4.1’s soundness gate required Shape.isValid. For a long thread, the two cutter paths both fail that gate: the smooth analytic cutter is BRepCheck-valid but, when wound over ~40+ turns, OCCT’s boolean degenerates to a near-no-op (the result keeps ~the full blank volume, no groove cut); the faceted screw-loft fallback does cut the groove correctly but trips BRepCheck on a benign facet self-intersection (isValid == false), exactly the #193 symptom. With both rejected, the method returned nil.

Fix. Soundness is now judged on geometry, not BRepCheck: the cut must stay inside the blank (tight/optimal envelope) and remove a sane fraction of the volume. isValid is no longer a gate. The analytic no-op is still rejected (it removes ~0 material → fails the volume check), so a long thread falls through to the faceted screw-loft and is returned, dimensionally correct and STEP-exportable, as the downstream reporter confirmed. Short/medium threads still get the smooth analytic helicoid and remain isValid == true; only the long faceted fallback is allowed to be invalid-but-usable.

v1.4.1 (June 2026), smooth analytic thread helicoid, with screw-loft fallback (#187)

PATCH, geometry quality, no API change. threadedShaft / threadedHole now emit a smooth analytic helicoid instead of v1.4.0’s faceted ruled loft. Same signatures, same in-envelope result; the difference is surface quality and face count.

What changed. v1.4.0 swept the V-profile through ~14 screw-transformed sections per turn and ruled-lofted them, correct and in-envelope, but faceted (hundreds of flank facets) and ~1 s per thread. The cutter is now built analytically (new bridge op OCCTShapeBuildThreadCutter): the four ISO-68 V-corners each trace a single BSpline helix (GeomAPI_Interpolate), and the solid is bounded by four ruled faces between consecutive corner-helices plus two V end caps, sewn, made solid, and BRepLib::OrientClosedSolid-corrected. That’s ~6 faces, no faceting, regardless of turn count.

Automatic fallback. OCCT’s boolean chokes on the tightly-wound cutter of small, fine-pitch threads (e.g. M5×0.8, 22.5 turns at radius 2.5): the subtraction comes back BRepCheck-“valid” but with more volume than the blank. The cut is validated (optimal/tight bounding box stays inside the blank and volume strictly decreases by a sane amount); if the analytic result fails, it silently falls back to v1.4.0’s robust screw-loft. So M6/M8/M10/M12 and coarse worm pitches get the smooth helicoid, while pathological small-fine-pitch threads still build via the faceted-but-robust path.

Why the envelope is measured on the optimal box. The smooth helicoid’s default Bnd_Box (BRepBndLib::Add) is the BSpline convex hull, which overshoots the real surface by ~0.1–0.35 mm , a control-pole artifact, not escaped material (AddOptimal returns the blank’s exact extent). Both the fallback check and the #181-C regression test now use the tight optimal box; a strict tolerance there still catches the real >1 mm balloon the guard exists for.

Migration. None required. Thread mesh/STEP geometry differs again (smoother), byte-exact snapshot consumers must rebaseline; everything else is unchanged.

v1.4.0 (June 2026), correct, in-envelope thread geometry (closes #187)

MINOR. BEHAVIOUR CHANGE to threadedShaft / threadedHole. The thread output geometry changes: both now produce a correct, in-envelope helicoid for every pitch, including coarse worm pitches that previously returned nil or garbage.

Why it changed. The cutter was a BRepOffsetAPI_MakePipeShell sweep of a V-profile along the helix. That sweep re-frames the section with the helix lead, so it bulged the thread outward (~1.25× cut depth for fasteners, ~3.1× for worm pitches → a self-intersecting ≈2×-radius balloon that crashed STEP export, #181-C/#185). The cutter is now built by a screw-motion sweep: the axial V-profile is transported by a pure rotate-about-axis + translate-along-axis motion (every section stays in its own axial plane), ruled-lofted, and subtracted. The result’s crest sits at the nominal radius (within ~0.1 mm tessellation), deterministically.

Migration. No API change (same signatures, still Shape?). But:

  • the produced thread mesh / STEP geometry differs, snapshot/byte-exact consumers must rebaseline;
  • threads that returned nil at coarse/worm pitch now return a valid solid;
  • the V-form is faceted (ruled loft, ~14 sections/turn) rather than a smooth pipe surface;
  • performance: ~1 s per thread (loft + boolean over the section facets). For many threads, expect it to dominate; a true analytic helical surface (future work) would remove the faceting/cost trade.

The #181-C envelope guard is retained as a thin safety net (now 1× cut depth) but effectively never trips on the in-envelope result.

v1.3.6 (June 2026), fix: thread envelope guard rejected valid fastener threads (closes #189)

PATCH, regression fix. The #181-C envelope guard added in v1.3.4 used a tolerance (1e-3 · extent) far tighter than the bounding-box overrun of a valid threadedShaft / threadedHole result, so it returned nil for ordinary bolts/screws (M5–M10, ISO 4762/4014/…) that built in v1.3.3, breaking 37 downstream fastener generators.

The guard’s tolerance is now 2 · cutDepth. Measured overruns (relative to the thread cut depth, which scales the corrected-Frenet sweep’s directional bulge) are ~1.25× for valid fastener threads and ~3.1× for the coarse-worm-pitch garbage the guard is meant to catch (#181-C, which balloons to ~2× radius and crashes STEP export). 2 · cutDepth sits cleanly between them, valid threads build again, the catastrophic balloon is still rejected. (The proper fix, a cutter that doesn’t bulge at all, is tracked in #187.)

v1.3.5 (June 2026), Shape.helicalSweep worm/screw-thread helicoid (closes #185)

PATCH, additive convenience API. Adds Shape.helicalSweep(profile:axisOrigin:axisDirection:radius:pitch:turns:clockwise:solid:) (and a multi-profile overload), the turnkey form of the #180 auxiliary-spine sweep for the helical case. It builds the helix spine and a correctly-spanning central-axis auxiliary spine internally, with the orientation flags (CurvilinearEquivalence = false, no contact) that keep the swept section radial, producing a worm/screw-thread helicoid in one call:

Shape.helicalSweep(profile: rib, axisOrigin: .zero, axisDirection: SIMD3(0,0,1),
                   radius: 5, pitch: .pi, turns: 4.77)   // crest stays radial (~Ø12), not nil

Hand-rolling this with pipeShell(mode: .auxiliary(...)) reliably returned nil because (a) Wire.helix runs toward +Z or −Z depending on handedness and (b) the auxiliary spine must span the helix’s full axial extent or the section planes never intersect it. The helper handles both. (Investigation: the correct OCCT recipe was confirmed empirically, SetMode(axisLine, CurvilinearEquivalence=false, NoContact); CurvilinearEquivalence=true and the contact modes fail to build for a helix spine.)

v1.3.4 (June 2026), assembly/export robustness (#181 B & C)

PATCH, robustness fixes, no API change.

  • STEP writer serialization (#181-B). Concurrent writeSTEP calls could SIGSEGV because OCCT’s STEPCAFControl/STEPControl writers share non-thread-safe Interface_Static globals with IGES. All STEP/IGES write entry points now serialize on the shared data-exchange mutex, so parallel exports queue instead of crashing. (The crash is an uncatchable signal, so internal serialization, not documentation, is the fix.)
  • threadedShaft envelope guard (#181-C). At coarse pitch / steep lead (and, observed here, even at bolt pitch) the helical V-cutter self-intersects and the boolean subtract returns a non-deterministic solid that BRepCheck reports “valid” yet extends well outside the blank (≈Ø22 on a Ø12 blank), which then crashed downstream STEP export. A thread cut can only remove material, so threadedShaft now returns nil when the result escapes the blank envelope rather than handing back garbage. Callers should fall back (e.g. a smooth-cylinder worm body).

Note on #181-A (XCAF setColor/setName on auto-created component labels): could not reproduce as an OCCT or bridge fault, XCAFDoc_ColorTool::SetColor on auto-created/reference component labels is robust in isolation, and the bridge already fails safe on unregistered labels. Left open pending a minimal reproducer.

v1.3.3 (June 2026), multi-section pipe shell (closes #180)

PATCH, additive API. Adds Shape.pipeShellMultiSection(spine:profiles:mode:withContact:withCorrection:solid:), the multi-section form of pipeShell. Several profiles positioned along the spine are swept into a single variable cross-section solid/shell via repeated BRepOffsetAPI_MakePipeShell::Add. Supports all orientation modes including .auxiliary(spine:), so a thread rib can ramp from a runout to full crest along a helix while staying radial, the worm-thread case that single-profile pipeShellWithLaw (Frenet-only, degenerates on near-zero scaling) could not express.

Shape.pipeShellMultiSection(spine: helix, profiles: [fullRib, runoutRib], mode: .auxiliary(spine: axis))

v1.3.2 (June 2026), fix loft (ThruSections) SIGSEGV on mismatched profiles (closes #176)

PATCH, robustness fix, no API change. Shape.loft (and any BRepOffsetAPI_ThruSections path) could SIGSEGV and abort the host process on mismatched closed profiles, e.g. machine-generated profile sets with differing vertex counts. The crash is an upstream OCCT null dereference in BRepFill_CompatibleWires::SameNumberByPolarMethod (unguarded correspondence-list iterator over-advance); because it surfaces as an OS signal, the bridge’s catch(...) could not intercept it.

Fixed by carrying a minimal source patch (Scripts/patches/0001-BRepFill_CompatibleWires-guard-polar-iterator.patch, applied by build-occt.sh) and rebuilding the xcframework. Loft now fails gracefully (nil) on such inputs. Reported and fixed upstream: OpenCASCADE/OCCT issue #1297, PR #1298.

Note: the OCC_CATCH_SIGNALS guards added in v1.2.1/v1.2.2 are inert in this build (OCCT is not compiled with OCC_CONVERT_SIGNALS) and do not provide signal safety; this patch addresses the crash at its source instead.

v1.3.1 (June 2026), feature-aware patterning, sweep orientation, geometric edge selection (closes #169, #170, #171)

PATCH, additive helpers + one orientation fix. Three ergonomics gaps surfaced building the OCCTSwiftScripts cookbook recipes (pipe-flange, helical-spring, mounting-bracket). No C++ bridge change, everything composes existing tested primitives.

  • #169, feature-level circular pattern. circularPattern duplicates the body, so the bolt-circle intent (“drill one hole, repeat it around the axis”) produced overlapping flange copies with the holes filled in. New Shape.circularPatternCut(tool:axisPoint:axisDirection:count:angle:) patterns the tool and subtracts the compound in one call; circularPattern’s doc now warns it patterns the body, not features.

    let flange = blank.circularPatternCut(tool: hole, axisPoint: .zero,
                                          axisDirection: SIMD3(0,0,1), count: 8)
    
  • #170, sweep orientation. Shape.sweep (BRepOffsetAPI_MakePipe) could yield an inward-oriented (negative-volume) solid depending on the section wire’s sense vs. the path tangent, a hazard for booleans and volume > 0 checks. sweep now orientation-normalises its result. New Shape.orientedForward() applies the same fix explicitly, and Shape.signedVolume exposes the signed BRepGProp mass for orientation diagnostics (unlike volume, which masks negatives as nil).

  • #171, geometric edge selection. Picking fillet edges by raw edges() index is fragile, the index shifts with parameters. New selectors return edges that feed straight into filleted(edges:radius:): concaveEdges() / convexEdges() (classified via BRepOffset_Analyse), edges(where:), edges(parallelTo:tolerance:), and edges(inBounds:_:).

    let rounded = bracket.filleted(edges: bracket.concaveEdges(), radius: 3)
    

v1.3.0 (June 2026), full 4×4 XCAF component locations (closes #174)

MINOR, additive new public API. XCAF assembly components could previously only be placed by a translation, so true instanced assemblies (shared geometry under arbitrary rigid transforms) lost their rotations. Document.addComponent(matrix:) now accepts a full 4×4 placement (row-major 12), and shape-driven instancing via Shape.located(matrix:) + addShape(makeAssembly: true) dedupes by shared TShape so each unique solid is written once with N located occurrences.

v1.2.2 (June 2026), broaden OCC signal guards (#175)

PATCH, robustness. Extended OSD::SetSignal + OCC_CATCH_SIGNALS coverage to the validity, volume, boolean, extrude, and revolve bridge paths (on top of v1.2.1’s loft/mesh/transform guards), so more degenerate-input failures surface as caught errors rather than aborting the process. Note: OCC_CATCH_SIGNALS is a no-op unless OCC_CONVERT_SIGNALS is defined, and converting via setjmp/longjmp bypasses C++ unwinding, so this hardens, but does not fully tame, deterministic SIGSEGVs on degenerate machine-generated geometry (see #176).

v1.2.1 (June 2026), OCC signal handling on loft/mesh/transform (#175)

PATCH, robustness. Installed OSD::SetSignal and wrapped the loft (ThruSections), mesh, and transform bridge entry points in OCC_CATCH_SIGNALS so OCCT hardware-signal faults on those paths convert to catchable failures instead of crashing the caller.

v1.2.0 (June 2026), TopologyGraph attribute store + Codable snapshot (closes #168)

MINOR, additive new public API. TopologyGraph nodes were bare (kind, index) pairs with no payload, and the type had no serialization (it wraps an opaque C++ handle). This adds a pure Swift-side sidecar so callers can attach arbitrary typed metadata to any NodeRef and round-trip it. No C++ bridge change, the store never touches the C++ graph.

extension TopologyGraph {
    public var attributes: NodeAttributeStore            // per-node typed metadata
    public func attribute(_ key: String, for: NodeRef) -> AttrValue?
    public func setAttribute(_ key: String, _ value: AttrValue, for: NodeRef)
    public func snapshot() throws -> GraphSnapshot        // export attributes + source shape
    public convenience init(snapshot: GraphSnapshot) throws  // rebuild + reattach
}
  • AttrValue: closed Codable enum: bool / int / double / string / ints / doubles (ints for mesh-region index sets, doubles for fitted-surface params).
  • NodeAttributeStore: Codable, keyed by NodeRef, encodes as sorted arrays so element order is deterministic; pair with GraphSnapshot.canonicalEncoder() (.sortedKeys) for byte-stable, diffable output.
  • GraphSnapshot: Codable round-trip. The graph structure is not serialized; it is re-derived by rebuilding from the source shape’s BREP (captured at construction). Rebuild pins parallel: false; a determinism test verifies NodeRef indexing is stable across rebuilds.
  • NodeKind and NodeRef gained Codable.

Foundation for the OCCTReconstruct mesh-to-solid pipeline (per-node fit residual / confidence / provenance + session persistence) and for OCCTMCP’s planned reconstruct_* read/write graph tools (OCCTMCP #33).

v1.1.0 (May 2026), TopologyGraph history disambiguation (closes #167)

First MINOR bump under the cohort SemVer policy. Two new methods on TopologyGraph resolve the ambiguity in findDerived’s empty-result case:

extension TopologyGraph {
    /// True iff any history record names `original` as a key.
    public func hasHistoryRecord(for original: NodeRef) -> Bool

    /// findDerived if non-empty; else [] for explicitly-deleted nodes;
    /// else [original] for untouched nodes (still at the same index).
    public func findDerivedOrSelf(of original: NodeRef) -> [NodeRef]
}

findDerived returned [] for both “untouched” and “explicitly deleted”, selection-remap consumers couldn’t tell which. findDerivedOrSelf is the typical “where did this node end up?” lookup: a single deterministic call that returns derivatives, [] for deleted, or [original] for untouched. hasHistoryRecord is the lower-level disambiguator for callers that want to handle the cases differently at the call site.

Implementation is a Swift-side scan over historyRecords, O(records × originals-per-record), which is fine for typical scenes. A bridge-side accelerator can land later if profiling ever justifies it.

Downstream impact: OCCTMCP v1.3.0 currently works around this with an isIdentityPreserving flag on its HistoryRegistry for transform_body / heal_shape. Once OCCTMCP picks up this OCCTSwift bump, it can drop the flag for ops that record explicit modify/delete records and use per-node resolution.

Op count: 4,284 → 4,286 (+2). xcframework binary unchanged from v1.0.0.

v1.0.4 (May 2026), wire applyFillet / applyChamfer through *WithFullHistory (closes #166)

Closes the explicit follow-up to v1.0.3: FeatureReconstructor.BuildResult.histories[id] now also covers FeatureSpec.Fillet and FeatureSpec.Chamfer with non-nil ids, every spec kind now resolves through OCCT’s recorded history instead of the centroid-distance heuristic on the consumer side.

Behavior changes:

  • applyFillet for all three EdgeSelector cases (.all, .nearPoint, .onFeature) now uses Shape.filletedWithFullHistory(radius:edges:) and records the returned ShapeHistoryRef in ctx.histories[id].
  • applyChamfer does the same via Shape.chamferedWithFullHistory(distance:edges:). Chamfer’s .nearPoint and .onFeature selectors are now wired up, they were stubbed to recordSkip(.unsupported) in v1.0.3 and earlier.
  • Each path falls back to the index-less primitive (filleted(radius:) / chamfered(distance:)) on builder-nil to preserve existing back-compat semantics. Specs without ids continue to land directly on the non-history path.

Internals: the per-selector helpers now return [Int]? matching-edge-index lists instead of pre-cooked Shape? results. This consolidates the resolution machinery between fillet and chamfer (chamfer used to duplicate fillet’s .all-only path because it had no shared resolver). The OCCTSwiftIO and OCCTMCP-side consumers that read BuildResult.histories[id] get fillet / chamfer coverage without any code change.

Out of scope: variable-radius fillet via FeatureSpec (the filletedWithFullHistory(edge:startRadius:endRadius:) Tier 2 variant), FeatureSpec.Fillet only carries one radius. Variable-radius would be a new spec variant.

v1.0.3 (May 2026), full per-input history Tier 2 & Tier 3 (issue #165)

Completes #165. Builds on the boolean-history surface in v1.0.2 by extending it to modification ops and threading history capture through FeatureReconstructor.

Tier 2, modification ops with full history (+5 ops):

extension Shape {
    func filletedWithFullHistory(radius: Double, edges: [Int])
        -> (result: Shape, history: ShapeHistoryRef)?
    func filletedWithFullHistory(edge: Int, startRadius: Double, endRadius: Double)
        -> (result: Shape, history: ShapeHistoryRef)?
    func chamferedWithFullHistory(distance: Double, edges: [Int])
        -> (result: Shape, history: ShapeHistoryRef)?
    func shelledWithFullHistory(facesToRemove: [Int], thickness: Double, tolerance: Double = 1e-3)
        -> (result: Shape, history: ShapeHistoryRef)?
    func defeaturedWithFullHistory(faces: [Int])
        -> (result: Shape, history: ShapeHistoryRef)?
}

All five reuse the existing OCCTBooleanHistory opaque handle (the underlying type stores a unique_ptr<BRepBuilderAPI_MakeShape>, which is the common base of every OCCT modification builder). For consumers, the API matches Tier 1, history.record(of: inputSubShape) returns the ShapeHistoryRecord of Modified / Generated / IsDeleted lookups.

Tier 3, FeatureReconstructor.BuildResult.histories:

public struct BuildResult: Sendable {
    // … existing fields …
    public let histories: [String: ShapeHistoryRef]
}

Per-feature ShapeHistoryRef keyed by the feature id. Populated when:

  • A boolean spec (FeatureSpec.Boolean) with non-nil id resolves successfully, captured from unionWithFullHistory / subtractedWithFullHistory / intersectionWithFullHistory
  • A hole spec (FeatureSpec.Hole) with non-nil id, captured from the underlying subtract
  • An additive feature (revolve/extrude/sheet-metal) with non-nil id whose absorbAdditive step fuses into a non-empty current, captured from the union

Features without an id aren’t keyed, and the existing applyFillet / applyChamfer paths still go through the non-history primitives (those cases need edge/face index computation that’s tracked as a separate refinement).

This unblocks OCCTMCP’s remap_selection for the apply_feature tool: instead of falling back to centroid-distance heuristics on splits / merges / deletions, the consumer can now walk BuildResult.histories[feature_id].record(of: subshape) for the exact OCCT-recorded mapping.

Op count: 4,279 → 4,284 (+5 Tier 2 entry points). xcframework binary unchanged from v1.0.0; SPM consumers continue to resolve against the v1.0.0 asset.

v1.0.2 (May 2026), per-input boolean history (issue #165 Tier 1)

Additive feature for selection-remapping consumers (#165). Adds a per-input-subshape history lookup surface to the four BRepAlgoAPI boolean ops, addressing OCCTMCP’s remap_selection need to walk selection IDs across boolean / split mutations exactly (instead of the centroid-distance heuristic that loses on splits / merges / deletions):

extension Shape {
    func unionWithFullHistory(_ other: Shape) -> (result: Shape, history: ShapeHistoryRef)?
    func subtractedWithFullHistory(_ tool: Shape) -> (result: Shape, history: ShapeHistoryRef)?
    func intersectionWithFullHistory(_ other: Shape) -> (result: Shape, history: ShapeHistoryRef)?
    func splitWithFullHistory(by tool: Shape) -> (pieces: [Shape], history: ShapeHistoryRef)?
}

public final class ShapeHistoryRef: @unchecked Sendable {
    func record(of inputSubShape: Shape) -> ShapeHistoryRecord  // .modified / .generated / .isDeleted
}

The ShapeHistoryRef retains the OCCT builder so Modified / Generated / IsDeleted stay queryable after the operation completes. Existing BooleanResult / BooleanHistoryResult callers are unchanged, pure additive surface.

Bug fix on the way. While building the history-handle plumbing I found that the new probe-then-fill helpers returned 0 when called with maxCount=0 (or outRefs=null), breaking the Swift-side count-then-allocate idiom. Fixed: the new bridge functions now always return the full count and only stop writing when count >= maxCount. Existing callers were unaffected (none used the probe path).

xcframework binary unchanged from v1.0.0 (no OCCT version change). SPM consumers continue to resolve against the v1.0.0 asset.

Out of scope for this release (will land in follow-ups under #165 Tiers 2 / 3): filletedWithFullHistory / chamferedWithFullHistory / shelledWithFullHistory / defeaturedWithFullHistory, and FeatureReconstructor.BuildResult.history.

v1.0.1 (May 2026), TopologyGraph.rootNodes fix + test repair

Bug fix. TopologyGraph.NodeKind was missing product = 10 and occurrence = 11 cases, so rootNodes silently returned [] even when products were present (compactMap { NodeKind(rawValue: 10) } filtered every entry out as nil). After OCCT 8.0.0 beta1 reshaped root iteration to “Products only”, every rootNodes consumer hit this. Fixed by extending the enum to cover the full BRepGraph_NodeId::Kind range (topology 0–8, assembly 10–11; slot 9 reserved upstream).

Tests. The four pre-existing failures shipped with v1.0.0 are repaired:

  • hasRoots and childExplorer now wrap the box’s solid in a Product via linkProductToTopology before querying rootNodes (matches OCCT 8.0 GA assembly semantics).
  • edgeVertexDistance switched from low-level BRepExtrema_DistanceSS (which deliberately skips edge-vertex pairs whose closest point is at an endpoint, expecting the caller to also pair vertices-with-vertices) to high-level Shape.distance(to:) backed by BRepExtrema_DistShapeShape, which orchestrates all subshape combinations including endpoint cases.
  • edgeSelectorFeatureUnsupported deleted, it asserted Fillet.onFeature was unsupported, contradicting the newer filletOnFeature test that asserts the opposite. .onFeature is wired up in FeatureReconstructor.

xcframework binary is unchanged from v1.0.0; SPM consumers continue resolving against the v1.0.0 asset.

v1.0.0 (May 2026), OCCT 8.0.0 GA, SemVer-stable

OCCTSwift reaches SemVer-stable v1.0.0, pinned to OpenCASCADE Technology 8.0.0 GA (released 2026-05-07, commit d3056ef8 on Open-Cascade-SAS/OCCT). After eight months of pre-1.0 development across 170+ point releases, wrapping ~4,275 OCCT operations across 1,160+ test suites, the public Swift API is stable from this point on. Pin to from: "1.0.0" in Package.swift.

OCCT 8.0.0 GA highlights since rc5 (per OCCT discussion #1275):

  • BRepGraph (graph-based topology) and Gordon Surfaces shipped in their final shape
  • TKHelix toolkit (geometric helix with B-spline approximation)
  • ExtremaPC specialized point-to-curve extrema with variant dispatching
  • STEP read/write thread safety: “safe under the contract of one reader or writer per thread”
  • Multiple SEGV fixes in chamfer, fillet, and pipe-shell operations
  • BSpline evaluation bugs corrected; geometry hashing implementations completed
  • C++17 minimum (already required by Swift 6); Standard_Failure inherits std::exception

Beta2 → GA breaking changes absorbed in this release:

  • PointSetLib removed. OCCT introduced PointSetLib_Props / PointSetLib_Equation in 8.0.0 beta1 (rc5/PCA point-cloud analysis) and removed them before GA. The Swift PointSetLib enum and bridge wrappers were deleted to follow upstream. If you depended on PointSetLib.properties / barycentre / inertiaMatrix / equation, port to your own NumPy/Accelerate implementation; the OCCT primitives are no longer available at any layer.
  • CoEdge continuity setters consolidated into setEdgeRegularity. OCCT 8.0.0 GA moved continuity from per-coedge to per-(edge, face1, face2) (in BRepGraph_LayerRegularity). The pre-GA setCoEdgeContinuity / setCoEdgeSeamContinuity / setCoEdgeSeamPairId are replaced by a single TopologyGraph.setEdgeRegularity(_:face1:face2:continuity:) -> Bool. For seam continuity, pass the same face index as face1 and face2. Explicit seam-pair-id is gone, seam-pair-id is structural in GA (two coedges on the same edge/face with opposite orientations); query via the existing coedgeSeamPair accessor.

Removed deprecated:

  • TopologyGraph.occurrenceParentOccurrence(_:): deprecated in v0.157.0 when OCCT 8.0.0 beta1 reshaped assembly topology to Product → Occurrence → Product. Use occurrenceParentProduct(_:).

Looking ahead: OCCTSwift now moves to a work-on-branch strategy for upstream OCCT changes; main stays release-quality. Future OCCT releases land in feature branches and graduate to a tagged OCCTSwift release only when the upstream is GA.

v0.171.0 (May 2026), ML-export hoist to OCCTSwiftIO

Breaking change. The consumption-side ML repacking layer added in v0.136.0 (TopologyGraph.GraphExport, exportForML(), exportJSON()) has been removed and lifted to OCCTSwiftIO v0.2.0 per OCCTSwiftIO#1 (supersedes OCCTSwift#71). It’s pure batch / headless workflow with no Viewport dependency, fits the OCCTSwiftIO charter, doesn’t need to live in the kernel.

What stays in the kernel (and why): FaceGridSample, sampleFaceUVGrid(faceIndex:uSamples:vSamples:), and sampleEdgeCurve(edgeIndex:count:). Their implementations call C bridge functions on TopologyGraph.handle, which is internal to this module. Lifting them would require widening visibility, explicitly out of scope per the partial-lift decision recorded on the issue.

Consumer migration: direct callers of exportForML / exportJSON must add import OCCTSwiftIO alongside import OCCTSwift. Symbol resolution otherwise unchanged. Known external callers swept: OCCTSwiftScripts/Sources/occtkit/Commands/GraphML.swift, OCCTSwiftScripts/Sources/GraphML/main.swift.

Net deltas: −124 LOC in BRepGraph.swift, −76 LOC in ShapeTests.swift. xcframework binary unchanged (no bridge changes).

v0.170.1 (May 2026), ShapeMeasurements kernel hoist + OCCTBridge.mm split complete

ShapeMeasurements moved to kernel (#100, PR #163). ShapeMeasurements (per-face areas / centroids / perimeters + per-edge lengths) and Shape.measure(linearTolerance:) are now part of OCCTSwift itself, no longer requiring a dependency on OCCTSwiftTools. Pure Swift relocation, no bridge changes. Existing OCCTSwiftTools.ShapeMeasurements callers should re-target to import OCCTSwift once OCCTSwiftTools ships its dep bump (tracked in OCCTSwiftTools#13).

OCCTBridge.mm split, DONE (#99, PRs #160-#162). The monolithic OCCTBridge.mm is now 393 lines of pure foundation (header includes, global mutex, OCCTSewing struct, Internal.h import), down from 58,168 lines pre-split (−99.3%). All 4,281 operations live in 15 per-OCCT-module translation units (OCCTBridge_Modeling.mm, OCCTBridge_Topology.mm, OCCTBridge_Healing.mm, OCCTBridge_Properties.mm, OCCTBridge_Geom2d.mm, OCCTBridge_Surface.mm, OCCTBridge_Curve3D.mm, OCCTBridge_Document.mm, OCCTBridge_IO.mm, OCCTBridge_Mesh.mm, OCCTBridge_Spatial.mm, OCCTBridge_BRepGraph.mm, OCCTBridge_AIS.mm, OCCTBridge_Visualization.mm, OCCTBridge_ProjLib_NLPlate.mm). Net-zero behavior change throughout; public C surface unchanged. The xcframework binary is identical to v0.170.0 (no OCCT changes), so SPM consumers can continue using the v0.170.0 binary URL.

v0.170.0 (May 2026), OCCT 8.0.0-beta2 ingest

xcframework rebuilt against V8_0_0_beta2. No public API changes, beta2 is a small follow-up to beta1 with no API breakage. Final 8.0.0 release remains targeted for May 7, 2026.

Upstream changes that landed in beta2:

  • Thread-safe STEP write + STEP/IGES read (OCCT #1259), fixes libmalloc double-free under concurrent STEPControl_Writer::Transfer and intermittent crashes in concurrent STEP/IGES readers. Contract: one reader/writer per thread; STEP read + write safe under that contract; IGES read still requires explicit serialization. OCCTSwift already serializes IGES via igesMutex() and STEP via occtGlobalMutex(), so the upstream fix is a net safety improvement without requiring bridge changes.
  • CPU grid path restored (OCCT #1252), the classical Graphic3d_Structure-based grid removed in beta1 is back as a coexisting backend. Doesn’t surface in OCCTSwift (no grid API exposed).
  • Documentation refresh + samples directory + CI warning cleanup: internal to upstream; no impact on consumers.

OCCTSwift surface unchanged: 4,281 wrapped operations, 3,393 tests, 1,178 suites, identical Swift OCCTSwift.* API.

v0.169.0 (May 2026), Mesh + export progress (issue #98 follow-up)

Extends the ImportProgress channel from v0.168 to two more long-running OCCT operations called out as out-of-scope in the original issue: BRepMesh_IncrementalMesh::Perform and the STEP / IGES writers. Same protocol, same cancellation contract.

New Swift API:

extension Shape {
    /// Run BRepMesh_IncrementalMesh with progress + cooperative cancellation.
    /// Throws ImportError.cancelled if cancelled.
    @discardableResult
    public func meshWithProgress(
        linearDeflection: Double = 0.1,
        angularDeflection: Double = 0.5,
        progress: ImportProgress? = nil
    ) throws -> Shape
}

extension Exporter {
    /// Export a shape to STEP with progress + cancellation.
    /// Throws ExportError.cancelled if cancelled.
    public static func writeSTEP(shape: Shape, to url: URL, progress: ImportProgress?) throws

    /// Export a shape to IGES with progress + cancellation.
    public static func writeIGES(shape: Shape, to url: URL, progress: ImportProgress?) throws
}

extension Document {
    /// Write the document to a STEP file with progress + cancellation.
    /// Throws ImportError.cancelled if cancelled.
    public func writeSTEP(to url: URL, progress: ImportProgress?) throws
}

extension ExportError {
    case cancelled
}

Bridge plumbing: 5 new entry points (OCCTShapeIncrementalMeshProgress, OCCTExportSTEPProgress, OCCTExportSTEPWithModeProgress, OCCTExportIGESProgress, OCCTDocumentWriteSTEPProgress) reusing the existing BridgeProgressIndicator from v0.168. BRepMesh_IncrementalMesh::Perform(Message_ProgressRange&), STEPControl_Writer::Transfer(...range), IGESControl_Writer::AddShape(...range), and STEPCAFControl_Writer::Transfer(...range) all accept the indicator’s progress range.

Why ImportProgress is the type for export too: it’s the same channel, progress + cancel. Adding parallel ExportProgress/MeshProgress protocols would multiply types without functional benefit. The protocol name reads slightly oddly in export contexts; pre-1.0 we accept that, and v1.0 will likely rename to OperationProgress.

6 new tests cover meshing progress + cancellation, STEP/IGES export with progress: nil (back-compat), STEP export progress fires, and Document.writeSTEP(to:progress:) round-trip.

v0.168.0 (May 2026), STEP/IGES import progress + cancellation (issue #98)

Wraps OCCT’s Message_ProgressIndicator so callers of Shape.loadSTEP / loadIGES / loadIGESRobust and Document.load / loadSTEP can observe progress and cooperatively cancel long-running imports.

New Swift API:

public protocol ImportProgress: AnyObject, Sendable {
    func progress(fraction: Double, step: String)
    func shouldCancel() -> Bool   // default: false
}

extension ImportError {
    case cancelled
}

extension Shape {
    public static func loadSTEP(from url: URL, progress: ImportProgress? = nil) throws -> Shape
    public static func loadSTEP(from url: URL, unitInMeters: Double, progress: ImportProgress? = nil) throws -> Shape
    public static func loadIGES(from url: URL, progress: ImportProgress? = nil) throws -> Shape
    public static func loadIGESRobust(from url: URL, progress: ImportProgress? = nil) throws -> Shape
}

extension Document {
    public static func load(from url: URL, progress: ImportProgress? = nil) throws -> Document
    public static func loadSTEP(from url: URL, progress: ImportProgress? = nil) throws -> Document
    public static func loadSTEP(from url: URL, modes: STEPReaderModes, progress: ImportProgress?) throws -> Document
}

progress: nil (the default) keeps existing call sites source-compatible, no behavioural change for callers that haven’t opted in.

Bridge plumbing: 7 new *Progress C entry points in OCCTBridge plus an internal BridgeProgressIndicator subclass of Message_ProgressIndicator that forwards Show() to a Swift callback (via opaque userData + @convention(c) trampoline) and reports UserBreak() == true when the Swift shouldCancel() returns true. STEPControl_Reader::TransferRoots, IGESControl_Reader::TransferRoots, and STEPCAFControl_Reader::Transfer all accept the indicator’s progress range.

Cancellation contract: shouldCancel() is polled at OCCT’s progress checkpoints (typically once per transferred entity). Returning true causes the loader to throw ImportError.cancelled at the next boundary. The shape / document is not partially constructed.

4 new tests cover (1) progress callback fires for a round-tripped STEP file, (2) progress: nil back-compat path still works, (3) cancellation flag honored, (4) Document.load progress.

Driver: unblocks OCCTSwiftTools v0.4.0, its CADFileLoader.load(from:format:) async API can now pass progress straight through, giving OCCTSwiftAIS’ file-open dialog a real progress bar and cancel button “for free”.

v0.167.0 (May 2026), visionOS + tvOS support

OCCT.xcframework now ships seven slices:

Platform Slice
macOS 12+ arm64 macos-arm64
iOS 15+ device arm64 ios-arm64
iOS 15+ Simulator arm64 ios-arm64-simulator
visionOS 1+ device arm64 xros-arm64 (new)
visionOS 1+ Simulator arm64 xros-arm64-simulator (new)
tvOS 15+ device arm64 tvos-arm64 (new)
tvOS 15+ Simulator arm64 tvos-arm64-simulator (new)

Package.swift declares .visionOS(.v1) and .tvOS(.v15) alongside the existing .iOS(.v15) / .macOS(.v12). The xcframework asset attached to this release is ~341 MB (up from 148 MB at v0.165.0; quadruples the slice count).

Build script changes (Scripts/build-occt.sh), required to make OCCT 8 cross-compile cleanly to visionOS and tvOS SDKs:

  • Added four new build blocks (visionOS device, visionOS Simulator, tvOS device, tvOS Simulator).
  • Each new block sets -DCMAKE_SIZEOF_VOID_P=8 to bypass OCCT’s OCCT_MAKE_COMPILER_BITNESS cmake macro, which couldn’t autodetect pointer size on the visionOS SDK (32 + 32*(/8) syntax error from an empty CMAKE_C_SIZEOF_DATA_PTR).
  • Removed explicit -mtargetos= / -m*-version-min= flags from the C/CXX flags, clang rejects them when CMake already sets --target=arm64-apple-xros1.0 from the SDK + deployment target. Letting CMake derive the target is the correct path.
  • xcframework creation step now conditionally includes each platform slice: if a slice fails to build (empty .a), the xcframework is built without it instead of aborting the whole script.

OCCT.xcframework.zip checksum: 5147b7d65cd9af5a6c3af1b38a1492365e645ed5c76a663bf9311c2f54043d87.

v0.166.1 (May 2026), Platform plan refinement

Metadata-only patch revising the v1.0.0 platform expansion plan:

  • Dropped Intel Mac (macOS x86_64). Apple is winding down Intel macOS support; not worth the build slot.
  • visionOS confirmed for v1.0.0. Device + simulator slices.
  • tvOS reduced to “if cheap”. Will only add if it falls out of the visionOS work without extra effort.
  • Linux / Windows / Android, moved to “under review” with a full analysis in docs/platform-expansion.md. Headline: Linux is the strongest non-Apple candidate (~2 weeks of focused work), Windows is medium-risk, Android should wait for Swift-on-Android packaging to stabilize. The prerequisite for any non-Apple port is the OCCTBridge .mm → .cpp audit, which is independently useful.

v0.166.0 (May 2026), Swift Package Index readiness

Preparation for a public listing on Swift Package Index alongside v1.0.0. No code changes; metadata only.

Added:

  • .spi.yml: SPI build matrix declaration:
    • macOS via SPM on Swift 6.0, 6.1, 6.2, 6.3
    • iOS on Swift 6.3
    • DocC documentation target: OCCTSwift
  • CODE_OF_CONDUCT.md: short pointer to Contributor Covenant 2.1 with reports email.
  • README:
    • SPI shields.io badges (Swift versions, platforms), activate once the package is added to SPI.
    • Updated install snippet from stale from: "0.128.0" to current from: "0.165.0".
    • “Supported Platforms” table covering current support and v1.0.0 expansion plan (Intel Mac, visionOS).
    • Documented Swift 6.1+ verified clean against 6.1 / 6.2 / 6.3 toolchains.

Submission gating: waiting until v1.0.0 ships (May 7, 2026, alongside OCCT 8.0.0 GA) before submitting to SPI. v0.166 makes the repo submission-ready.

v0.165.0 (May 2026), Fix SPM xcframework URL (issue #97)

Package.swift had its remote binaryTarget(url:) hardcoded to the v0.131.0 xcframework, predating OCCT 8 by months. SPM consumers pinning from: "0.157.0" resolved the version correctly but the build failed at compile-time with 'BRepGraph_MeshView.hxx' file not found because the v0.131.0 binary was built against rc-era OCCT and didn’t ship the beta1 headers that the v0.157+ wrappers reference. Local-path consumers were unaffected (the auto-detect picks up Libraries/OCCT.xcframework).

This release:

  1. Attaches the current beta1 xcframework as a release asset (OCCT.xcframework.zip, ~148 MB).
  2. Updates Package.swift’s remote URL to point at the v0.165.0 release and bumps the SPM checksum to 99bba63c0e686195512cfaa4f3f46f9f11c8b6cd89e8fe5b8aed872a48978003.

After this release, from: "0.165.0" resolves cleanly for remote-pin consumers and the v0.157.0 → v0.164.0 wrapper surface (MeshView, MeshCache, EditorView mutation, ProductOps, RepOps + cache inspection) becomes usable downstream. Downstream Package.swift consumers should bump their pin to from: "0.165.0".

No new ops; this is purely a packaging fix.

v0.164.0 (May 2026), RepOps non-guard setters & cache entry inspection (21 ops)

Final wrapping pass for OCCT 8.0.0 beta1 BRepGraph surface. After this release, the public surface of BRepGraph::EditorView and BRepGraph::MeshView is exhaustively wrapped on TopologyGraph.

RepOps non-guard setters: swap geometry / mesh content bound to an existing rep id without recreating the rep:

graph.repSetSurface(repId, surface: newSurface)
graph.repSetCurve3D(repId, curve: newCurve3D)
graph.repSetCurve2D(repId, curve: newCurve2D)
graph.repSetTriangulation(repId, triangulation: newTri)
graph.repSetPolygon3D(repId, polygon: newPoly3D)
graph.repSetPolygon2D(repId, polygon: newPoly2D)
graph.repSetPolygonOnTri(repId, polygon: newPolyOnTri)
graph.repSetPolygonOnTriTriangulationId(polyOnTriRepId, triRepId: newTriRepId)

Cache entry inspection: detailed access to the algorithm-derived cache tier for diagnostics and non-destructive mesh tooling:

graph.cachedFaceMeshIsPresent(0)              // Bool
graph.cachedFaceMeshTriRepCount(0)            // Int
graph.cachedFaceMeshActiveIndex(0)            // Int (-1 if absent)
graph.cachedFaceMeshStoredOwnGen(0)           // UInt32 (cache freshness gen)
graph.cachedFaceMeshTriRepId(0, repIndex: 0)  // Int? (active or specific entry)

graph.cachedEdgeMeshIsPresent(0)
graph.cachedEdgeMeshPolygon3DRepId(0)
graph.cachedEdgeMeshStoredOwnGen(0)

graph.cachedCoEdgeMeshIsPresent(0)
graph.cachedCoEdgeMeshPolygon2DRepId(0)
graph.cachedCoEdgeMeshPolygonOnTriRepCount(0)
graph.cachedCoEdgeMeshPolygonOnTriRepId(0, repIndex: 0)
graph.cachedCoEdgeMeshStoredOwnGen(0)

The StoredOwnGen accessors expose the cache freshness generation, pair with the entity’s current OwnGen (via existing readers) to detect stale cache entries.

3 new tests cover fresh-graph absence, post-appendCachedTriangulation state readback, and edge/coedge cache absence.

v0.163.0 (May 2026), EditorView ProductOps assembly building (5 ops)

Closes the EditorView mutation surface. With v0.163.0 the public mutation API of BRepGraph::EditorView is fully wrapped on TopologyGraph.

let parent = graph.createEmptyProduct()!
let child = graph.linkProductToTopology(
    shapeRootKind: 0, shapeRootIndex: 0,
    placement: TopologyGraph.identityLocationMatrix)!
let linked = graph.linkProducts(
    parentProductIndex: parent,
    referencedProductIndex: child,
    placement: TopologyGraph.identityLocationMatrix)!
// linked.occurrenceIndex, linked.occurrenceRefIndex

graph.productRemoveOccurrence(parent, occurrenceRefIndex: linked.occurrenceRefIndex)
graph.productRemoveShapeRoot(child)

linkProductToTopology accepts placement: nil for an identity placement. linkProducts takes a parentOccurrenceIndex: Int? (nil for unparented).

2 new tests cover the create/link path and remove-with-bogus-ids no-crash safety.

v0.162.0 (May 2026), EditorView geometric setters, location setters, PCurve API (16 ops)

Closes the EditorView wrapping started in v0.159.0. With v0.162.0 the public mutation surface of BRepGraph::EditorView is fully wrapped on TopologyGraph.

CoEdge geometric setters:

  • setCoEdgeUVBox(_:u1:v1:u2:v2:)
  • setCoEdgeContinuity / setCoEdgeSeamContinuity (GeomAbs_Shape: 0=C0, 1=C1, 2=C2, 3=C3, 4=CN)
  • setCoEdgeSeamPairId

Face geometric setter:

  • setFaceTriangulationRep(_:triRepId:): bind the active triangulation to a face’s persistent tier (vs appendCachedTriangulation for the cache tier)

CoEdge PCurve API (uses existing Curve2D Swift type):

  • coEdgeCreateCurve2DRep(_ curve2D:) → rep id
  • coEdgeSetPCurve(_ coedgeIndex:curve2D:) (pass nil to clear)
  • coEdgeAddPCurve(edgeIndex:faceIndex:curve2D:first:last:orientation:)

Location setters via 12-double 3x4 matrix (gp_Trsf::SetValues row-major convention):

  • setVertexRefLocalLocation, setCoEdgeRefLocalLocation, setWireRefLocalLocation
  • setFaceRefLocalLocation, setShellRefLocalLocation, setSolidRefLocalLocation
  • setOccurrenceRefLocalLocation, setChildRefLocalLocation
  • Convenience: TopologyGraph.identityLocationMatrix returns the 3x4 identity

3 new tests cover CoEdge geometric setters on real coedges, identity-matrix location setters on real refs, and face-triangulation binding with MeshView readback.

v0.161.0 (May 2026), EditorView Add / Remove / Ref setters (41 ops)

Continues the EditorView wrapping started in v0.159.0 with the structural-mutation surface:

Add operations (return ref id or nil):

  • edgeAddInternalVertex(_:vertexIndex:orientation:)
  • faceAddVertex(_:vertexIndex:orientation:)
  • shellAddChild(_:childKind:childIndex:orientation:)
  • solidAddChild(_:childKind:childIndex:orientation:)
  • compoundAddChild(_:childKind:childIndex:orientation:)
  • compSolidAddSolid(_:solidIndex:orientation:)

Remove operations (return Bool indicating active-usage removal):

  • edgeRemoveVertex, edgeReplaceVertex (returns new ref id)
  • wireRemoveCoEdge, faceRemoveVertex, faceRemoveWire
  • shellRemoveFace, shellRemoveChild
  • solidRemoveShell, solidRemoveChild
  • compoundRemoveChild, compSolidRemoveSolid
  • removeRep(repKind:repIndex:): generic representation removal

Ref setters (entity-ref → entity-def rebinding, orientation, rep-id binding):

  • Vertex: setVertexRefOrientation, setVertexRefVertexDefId
  • Edge: setEdgeStartVertexRefId, setEdgeEndVertexRefId, setEdgeCurve3DRepId, setEdgePolygon3DRepId
  • CoEdge: setCoEdgeRefCoEdgeDefId, setCoEdgeEdgeDefId, setCoEdgeFaceDefId, setCoEdgeCurve2DRepId, setCoEdgePolygon2DRepId, setCoEdgePolygonOnTriRepId, clearCoEdgePCurveBinding
  • Wire: setWireRefIsOuter, setWireRefOrientation, setWireRefWireDefId
  • Face: setFaceSurfaceRepId, setFaceRefOrientation, setFaceRefFaceDefId
  • Shell: setShellRefOrientation, setShellRefShellDefId
  • Solid: setSolidRefOrientation, setSolidRefSolidDefId
  • Occurrence: setOccurrenceChildDefId, setOccurrenceRefOccurrenceDefId
  • Generic: setChildRefOrientation, setChildRefChildDefId

Setters that need TopLoc_Location or Bnd_Box2d (e.g. *RefLocalLocation, CoEdge.SetUVBox, CoEdge.SetContinuity) are deferred until a 12-double / 4-double calling convention lands in the bridge.

3 new tests cover Add no-crash safety, Remove returning false on bogus ref ids, and Ref setters operating on real box ids without crashing.

v0.160.0 (May 2026), MeshCache write API + new Triangulation type

Completes the OCCT 8.0.0 beta1 two-tier mesh storage wrapping started in v0.158.0. The cache write side, BRepGraph_Tool::Mesh static helpers, is now exposed on TopologyGraph, and a new Triangulation Swift class wraps Handle<Poly_Triangulation> for input.

New Triangulation class (mirrors the existing Polygon3D / PolygonOnTriangulation pattern):

let tri = Triangulation.create(
    nodes: [SIMD3(0,0,0), SIMD3(1,0,0), SIMD3(0,1,0), SIMD3(1,1,0)],
    triangles: [0,1,2, 1,3,2]
)!
tri.nodeCount        // 4
tri.triangleCount    // 2
tri.node(at: 0)      // SIMD3(0, 0, 0)
tri.triangle(at: 0)  // (0, 1, 2)
tri.deflection = 0.01

Vertex indices are 0-based on the Swift boundary; the bridge handles OCCT’s 1-based convention internally.

MeshCache write API on TopologyGraph:

let triRepId = graph.createTriangulationRep(tri)!
graph.appendCachedTriangulation(faceIndex: 0, triRepId: triRepId)
graph.setCachedActiveIndex(faceIndex: 0, activeIndex: 0)

let polyRepId = graph.createPolygon3DRep(polygon3d)!
graph.setCachedPolygon3D(edgeIndex: 0, polyRepId: polyRepId)

let polyOnTriRepId = graph.createPolygonOnTriRep(polygonOnTri, triRepId: triRepId)!
graph.appendCachedPolygonOnTri(coedgeIndex: 0, polyRepId: polyOnTriRepId)
graph.setCachedPolygon2D(coedgeIndex: 0, poly2DRepId: ...)

This unblocks downstream tooling (OCCTMCP, OCCTSwiftScripts) that wants to populate algorithm-derived mesh data on a graph without touching the persistent (STEP-imported) tier, important for non-destructive meshing workflows.

4 new tests cover Triangulation construction round-trip, malformed-input rejection, and rep-creation + face/edge binding with subsequent MeshView readback.

v0.159.0 (May 2026), EditorView field setters

OCCT 8.0.0 beta1’s BRepGraph::EditorView exposes per-entity Ops classes with Set* methods that mutate field-level data on existing graph entities (without requiring a full topology rebuild). v0.159.0 wraps the simple-value subset (scalars, bools, orientations) on the TopologyGraph Swift type:

VertexOps: setVertexPoint(_:x:y:z:), setVertexTolerance(_:tolerance:)

EdgeOps: setEdgeTolerance, setEdgeParamRange(_:first:last:), setEdgeSameParameter, setEdgeSameRange, setEdgeDegenerate, setEdgeIsClosed

CoEdgeOps: setCoEdgeParamRange, setCoEdgeOrientation (Forward/Reversed/Internal/External as Int 0–3)

WireOps: setWireIsClosed

FaceOps: setFaceTolerance, setFaceNaturalRestriction

ShellOps: setShellIsClosed

All 14 setters are pass-through to the corresponding g.Editor().<Entity>().Set*(...) on the OCCT side. Invalid ids are no-ops (try/catch in bridge). Setters that require new opaque types, SetPCurve, SetSurfaceRepId, SetTriangulationRep, Mut* RAII guards, are deferred. Same with Add* / Remove* mutation methods that aren’t already wrapped via the Builder bridge functions.

Driver: lets headless tooling (OCCTMCP, OCCTSwiftScripts) tweak field-level data after constructing a graph (e.g. relax a tolerance, mark an edge degenerate) without round-tripping through TopoDS_Shape rebuilds.

4 new tests cover set-then-read-back where a getter exists, plus no-crash safety on the readback-less setters.

v0.158.0 (May 2026), MeshView two-tier mesh storage (read API)

OCCT 8.0.0 beta1 introduced a two-tier mesh storage model: an algorithm-derived cache (populated by BRepGraphMesh) and the persistent tier (mesh data imported from STEP, stored in topology definitions). v0.158.0 wraps the read-side of this model, BRepGraph::MeshView queries, exposing it on the existing TopologyGraph Swift type:

  • Counts: polygon2DCount, polygonOnTriCount, activeTriangulationCount, activePolygon3DCount, activePolygon2DCount, activePolygonOnTriCount. Pairs with the existing triangulationCount / polygon3DCount from v0.133.0.
  • Per-entity cache-first queries:
    • meshFaceActiveTriangulationRepId(_ faceIndex:) → optional rep id (cache-first, persistent fallback)
    • meshEdgePolygon3DRepId(_ edgeIndex:) → optional rep id (cache-first, persistent fallback)
    • meshCoEdgeHasMesh(_ coedgeIndex:) → bool (cache-only)

The Swift API is unchanged for existing call sites. Driver: prep for future BRepGraphMesh-driven workflows in OCCTMCP / OCCTSwiftScripts that want to introspect mesh state without invalidating the persistent tier.

The mesh write API (BRepGraph_Tool::Mesh::CreateTriangulationRep etc.) is intentionally not yet wrapped, it requires marshaling Handle<Poly_Triangulation> from Swift, which is a larger lift. Targeted for v0.159 or v1.0.

v0.157.0 (May 2026), OCCT 8.0.0 beta1 support (final pre-1.0 release)

xcframework rebuilt against V8_0_0_beta1. v1.0.0 will follow on May 7, 2026 pinned to the OCCT 8.0.0 GA tag.

Bridge migrations driven by upstream API churn since rc5:

  • BRepGraph_BuilderView removed (OCCT #1237) → migrated all 22 mutation entry points to BRepGraph_EditorView. Old: g.Builder().AddVertex(p, t); new: g.Editor().Vertices().Add(p, t). Swift API surface unchanged.
  • NCollection_Vector deprecated (OCCT #1230) → switched 4 internal sites to NCollection_DynamicArray, including the BRepGraph_History::Record mapping container.
  • Builder().AppendFlattenedShape / AppendFullShape consolidated → both now route through the static BRepGraph_Builder::Add(graph, shape, options). The Flatten and CreateAutoProduct options preserve the pre-beta1 distinction.
  • Builder().ClearFaceMesh / ClearEdgePolygon3D moved → now BRepGraph_Tool::Mesh::ClearFaceCache / ClearEdgeCache. Semantic shift: clears only the new cached-mesh tier, not persistent (STEP-imported) mesh data.
  • graph.Build(shape, parallel) removed → wrapper now calls the static BRepGraph_Builder::Add(graph, shape, opts) with CreateAutoProduct = false to preserve the historical “no auto Product wrap” behaviour.
  • graph.RootNodeIds() → graph.RootProductIds(): root iteration is now Products only.
  • BRepGraph_Copy::CopyFace → CopyNode: single-node deep copy now takes any NodeId kind.
  • Topo().Occurrences().ParentOccurrence removed: beta1 model is Product → Occurrence → Product; an occurrence has no parent occurrence. Wrapper retained as -1 sentinel for ABI; will be removed in v1.0.
  • BRepGraph_ChildExplorer::Current() returns BRepGraphInc::NodeInstance (was NodeUsage); field accessor unchanged.
  • BRepGraph_Tool::Edge::StartVertex / EndVertex renamed to StartVertexId / EndVertexId; return type simplified from a VertexRef struct to BRepGraph_VertexId.
  • Topo().Poly().Nb* moved to Mesh().Poly().Nb*, triangulation/polygon counts live on the new MeshView, paired with the two-tier mesh storage.

New beta1 surface (BRepGraph_MeshCache, BRepGraph_MeshView read-side, EditorView per-entity Ops methods, BRepGraph_Tool::Mesh cache-write API) is deferred to v0.158 / v1.0, kept v0.157 minimal to preserve the soak window.

The 1300+ existing tests continue to pass under serial execution (OCCT_SERIAL=1 with --num-workers 1); the pre-existing parallel-execution NCollection arm64 race remains the same as v0.156.

v0.156.3 (Apr 2026), Document.node(at:) warms up the labelId registry (issue #95)

The Document.node(at:) lookup added in v0.156.1 returned nil on a freshly-loaded STEP document if rootNodes hadn’t been walked first. Cause: the bridge’s labelId-to-TDF_Label registry is populated lazily via registerLabel(...) calls, OCCTDocumentLabelIsNull(0) reports null because labels[0] doesn’t exist yet. rootNodes warms it up because OCCTDocumentGetRootLabelId(handle, i) calls registerLabel, but OCCTDocumentGetRootCount alone doesn’t.

node(at:) now eagerly iterates root indices to register top-level labels before the IsNull check:

public func node(at labelId: Int64) -> AssemblyNode? {
    let rootCount = OCCTDocumentGetRootCount(handle)
    for i in 0..<rootCount { _ = OCCTDocumentGetRootLabelId(handle, i) }
    guard !OCCTDocumentLabelIsNull(handle, labelId) else { return nil }
    return AssemblyNode(document: self, labelId: labelId)
}

Deep-child labelIds aren’t registered by this warmup, those are expected to have been registered earlier by an explicit traversal (e.g. via node.children). The contract docstring spells this out.

mainLabel was checked for the same lazy-init quirk and is fine as-is, OCCTDocumentGetMainLabel calls registerLabel(main) itself.

Driver: OCCTSwiftScripts#23’s set-metadata verb. The downstream workaround (_ = document.rootNodes.count before node(at:)) can be removed.

One new regression test: load a STEP doc, look up node(at: 0) without touching rootNodes first, expect a non-nil node with labelId == 0.

v0.156.2 (Apr 2026), Public Mesh(vertices:normals:indices:) constructor (issue #94)

Mesh had internal init(handle:) and no public way to construct from raw vertex/index arrays. This blocked sibling packages (notably OCCTSwiftMesh) from returning Mesh instances produced by mesh-domain algorithms (decimation, smoothing, repair, remeshing) that operate purely on vertex/index buffers and have no B-Rep state.

let mesh = Mesh(
    vertices: [SIMD3(0, 0, 0), SIMD3(1, 0, 0), SIMD3(0, 1, 0)],
    indices: [0, 1, 2]
)

Optional normals: [SIMD3<Float>]? parameter, when nil, per-vertex normals are computed by averaging the face normals of adjacent triangles (smooth shading default). Per-triangle normals are always computed from the geometry. faceIndices is set to -1 for every triangle (no B-Rep source).

Failable initializer rejects: empty inputs, index count not divisible by 3, indices out of range, mismatched normals count.

Bridge: one new symbol OCCTMeshCreateFromArrays(vertices, vertexCount, normals, indices, indexCount) -> OCCTMeshRef?, caller releases via the existing OCCTMeshRelease. Unblocks OCCTSwiftMesh#1 (v0.1.0, Mesh.simplified(_:) via vendored meshoptimizer).

7 new tests covering round-trip, computed-normals correctness, supplied-normals preservation, and all four invalid-input rejection paths.

v0.156.1 (Apr 2026), Public AssemblyNode.labelId + Document.node(at:) lookup (issue #93)

AssemblyNode.labelId was internal even though every other Document API works in terms of Int64 labelIds (removeShape(labelId:), componentLabelId(...), expandShape(labelId:), etc.). Consumers walking the assembly via Document.rootNodes → AssemblyNode.children couldn’t read each node’s labelId to identify it across calls. Driver: OCCTSwiftScripts#23 (occtkit inspect-assembly / set-metadata) needs stable IDs that round-trip.

Two tiny additive changes:

// 1. labelId is now public
public let labelId: Int64

// 2. New lookup on Document
public func node(at labelId: Int64) -> AssemblyNode?

node(at:) validates the labelId via OCCTDocumentLabelIsNull (O(1), consistent with the rest of the int64-based Document API) and returns nil for unknown labelIds. LabelIds are stable within a single Document instance, round-trips with rootNodes traversal in the same session.

No bridge changes. Two new tests covering the round-trip and rejection of nonexistent labelIds.

v0.156.0 (Apr 2026), Quality release: drop deprecated GCE2d_* symbols

OCCT 8.0.0 deprecated the entire GCE2d_Make* family of 2D geometry constructors in favour of the canonical GC_Make*2d names, each old class is now literally a using GCE2d_X = GC_X2d typedef alias. This release migrates all internal C++ uses inside OCCTBridge.mm to the canonical names so we’re no longer building against deprecated identifiers.

GCE2d_MakeArcOfCircle   → GC_MakeArcOfCircle2d
GCE2d_MakeArcOfEllipse  → GC_MakeArcOfEllipse2d
GCE2d_MakeArcOfHyperbola → GC_MakeArcOfHyperbola2d
GCE2d_MakeArcOfParabola → GC_MakeArcOfParabola2d
GCE2d_MakeCircle        → GC_MakeCircle2d
GCE2d_MakeEllipse       → GC_MakeEllipse2d
GCE2d_MakeHyperbola     → GC_MakeHyperbola2d
GCE2d_MakeLine          → GC_MakeLine2d
GCE2d_MakeMirror        → GC_MakeMirror2d
GCE2d_MakeParabola      → GC_MakeParabola2d
GCE2d_MakeRotation      → GC_MakeRotation2d
GCE2d_MakeScale         → GC_MakeScale2d
GCE2d_MakeSegment       → GC_MakeSegment2d
GCE2d_MakeTranslation   → GC_MakeTranslation2d

14 #include directives + ~30 internal symbol uses migrated. Bridge ABI unchanged: the bridge’s own C function names (OCTGCE2dMake*) are preserved so Swift wrappers continue to call them by their existing names, this is a non-breaking internal hygiene release.

Operation count, test count, and suite count are unchanged, same OCCT objects, just constructed via canonical names. The @Suite("GCE2d_MakeLine") test label was renamed to @Suite("GC_MakeLine2d") for consistency. Source comments and // MARK: headers in Sources/OCCTSwift/Curve2D.swift and Sources/OCCTSwift/Document.swift were updated similarly.

This was the cleanup-half of a rescoped v0.156.0 plan. The OCAF/Message data introspection scope originally pencilled in for v0.156.0 was abandoned after a full audit revealed the project is at the asymptote of useful OCCT public surface, most flagged “missing” classes were already wrapped via the established OCCTDocumentRef + int64_t labelId pattern, and the genuinely unwrapped classes (~25 ops total: gp_Vec2f/3f, GeomConvert_FuncCone/Cylinder/SphereLSDist) are too small to justify a 100-op release on their own.

v0.155.1 (Apr 2026), Wire(_:Shape) convenience initializer (issue #91)

Completes the v0.154.0 trio. Recovers a typed Wire from a generic Shape that wraps a TopoDS_Wire, returning nil on type mismatch. Mirrors Face(_:Shape) and Edge(_:Shape).

let box = Shape.box(width: 10, height: 10, depth: 10)!
let wireShapes = box.subShapes(ofType: .wire)
if let wire = Wire(wireShapes[0]) {
    // typed Wire recovered from a wire-typed Shape
}

Unblocks face-rebuild flows where existing inner wires (returned as [Shape] from Shape.wires or subShapes(ofType: .wire)) need to be passed back into Shape.face(outer:holes:), previously those wires were stuck as Shape because the Wire(handle:) initializer was internal. Concrete motivating case: preserving both bore and chamfer outlines on the same mid-face when extracting countersink mid-surfaces in UnfoldEngine.

Bridge: one new symbol OCCTWireFromShape(OCCTShapeRef) -> OCCTWireRef?.

v0.155.0 (Apr 2026), SheetMetal.Builder: convex bends (issue #89)

The v0.151–v0.153 builder only supported concave bends (L-bracket-style, where the two flanges’ bodies overlap in volume around the seam). Convex bends, Z-section middle bends, offset brackets, gusseted brackets where one flange folds back on the opposite side, failed with BuildError.filletFailed because the seam edge is non-manifold (a kiss point with four boundary faces meeting at one line, which BRepFilletAPI_MakeFillet rejects).

v0.155 adds first-class convex bend support:

  • Auto-detected direction. Each bend is classified concave or convex from the relative position of the two flanges’ body centroids. No caller change needed; the existing v0.151–v0.153 fixtures (L, U, stepped Z) continue to build identically because they’re all concave.

  • Convex bend material. Convex bends build a curved-triangle prism that bridges the two flanges’ outer-corner edges with a cylindrical fillet on the outside surface, then boolean-fuses with the flanges. The “kiss point” stays sharp on the inside (which is the natural CAD interpretation when the user’s flange placements don’t leave room for an inside cylinder); the outside is rounded to the bend radius.

  • Bend struct expanded with optional explicit controls:
    • angle: Double?: bend angle in radians, signed (positive = concave, negative = convex). Nil means auto-infer from flange positions. Sign convention follows OCCT’s right-hand rule: angles are CCW-positive about the bend axis derived from cross(fromFlange.normal, toFlange.normal), with concave-positive matching how a CAD designer thinks about bends.
    • insideRadius: Double: replaces the legacy single radius (which still works as a convenience init).
    • outsideRadius: Double?: independent control of the outside fillet radius. Defaults to nil = match insideRadius for convex builds.
    • materialThicknessAtBend: Double?: allow thinner material in the bend region than the flange thickness, common in etched parts where a thinned bend line allows tighter folds without cracking.
    • direction: BendDirection: .auto (default), .concave, or .convex for explicit override.
  • The legacy Bend(from:to:radius:) initializer is unchanged. All v0.151–v0.153 callers continue to work without modification.

The 93-face inside-corner-reinforcing-bracket from #89 (Z-section with both same-direction and convex bends) now builds cleanly. Test fixtures from the issue: symmetric Z, offset L with very short web, channel-with-flange, all pass.

Bridge: one new symbol OCCTWireCreateArcThroughPoints(s, m, e) for 3-point arc-wire construction (avoids the gp_Ax2 X-direction ambiguity of the angle-based arc API). Exposed as Wire.arc(start:midpoint:end:).

v0.154.0 (Apr 2026), Face(_:Shape) and Edge(_:Shape) convenience initializers

Two tiny additive bridge symbols and their Swift conveniences. Recovers a typed Face or Edge from a generic Shape that wraps a TopoDS_Face / TopoDS_Edge (returns nil on type mismatch). Useful when a method gives back a Shape (e.g. subShapes(ofType: .face)) and you want the typed wrapper to call methods like area(), outerWire, length, etc., directly.

let box = Shape.box(width: 10, height: 10, depth: 10)!
let faceShapes = box.subShapes(ofType: .face)
if let face = Face(faceShapes[0]) {
    print(face.area())   // 100
}

Bridge: OCCTFaceFromShape(OCCTShapeRef) -> OCCTFaceRef? and OCCTEdgeFromShape(OCCTShapeRef) -> OCCTEdgeRef?. Both return NULL when the shape’s ShapeType() doesn’t match. Unblocks the upcoming UnfoldEngine package, which builds on these.

v0.153.0 (Apr 2026), SheetMetal.Builder step-aware bends (issue #86)

The v0.151 SheetMetal.Builder implementation extruded each flange at its full profile, fused them, then filleted the seam edge. That works when both flanges have matching extents along the seam direction, but fails on stepped seams, flanges that meet along less than their full extent (a narrow tab on a wider base, a U-channel with sides narrower than the spine). OCCT can’t cleanly fillet an edge that terminates at a free-face boundary, so the v0.151 builder reported BuildError.filletFailed and the downstream OCCTDesignLoop pipeline padded the narrower flange to match, both expensive and incorrect.

v0.153 lifts that limitation:

  • SheetMetal.Builder.build(flanges:bends:) now computes the seam intersection between each pair of flanges in a bend and splits the wider flange at the intersection endpoints before extruding. The matched-extent middle piece carries the bend; the outer pieces stay flat. The fillet machinery from v0.151 runs unchanged on the matched-extent piece, where it’s always well-formed.
  • For matched-extent inputs (where v0.151 already worked), the result is identical: the splitting step is a no-op.
  • Two new error cases: BuildError.seamsDoNotOverlap(fromID:toID:) if the two flanges’ seam edges don’t actually intersect along the seam line; BuildError.nonRectangularStepFlange(id:) if a flange would need to be split but its profile isn’t axis-aligned-rectangular (rectangular profiles cover the issue’s three test fixtures and the common cases; non-rectangular stepped seams are deferred).

The three reference fixtures from issue #86 all build cleanly:

  • L-bracket with 80×40 base + 20×30 centred mounting tab.
  • Z-bracket with 50×30 base + full-seam mid + 20×30 stepped top tab.
  • U-channel with 100×40 spine + 80×15 stepped side flanges (narrower than the spine in the seam direction).

OCCTDesignLoop’s eval/describer_to_features.py can drop its seam-padding workaround and emit actual described flange dimensions; the existing typed SheetMetal.Flange / SheetMetal.Bend API and the JSON envelope are unchanged.

The unrelated v0.151 limitation about the bend axis being on the outside corner (sharp inner corner, filleted outer corner) still applies, that’s a different construction (real inside-radius + outside-radius bend) and is filed separately.

v0.152.1 (Apr 2026), FeatureReconstructor.buildJSON decodes boolean (issue #88)

FeatureSpec.boolean (with op ∈ union | subtract | intersect, leftID, rightID) has been wired through applyBoolean since the typed Swift API landed, and v0.152’s inputBody makes it useful for cuts that reference the seeded body via @input. But the JSON decoder never picked it up, FeatureEntry.init(from:) had no case "boolean": branch, so JSON entries with "kind": "boolean" fell into the default: clause and were silently dropped.

  • Adds the case "boolean": decoder branch. Reads op (string), left, right, optional id. Coding keys for these were already declared.
  • Bad op rawValue surfaces as a recordable skip with reason unsupported("boolean(op:smush)") rather than throwing, matches the rest of the reconstructor’s “graceful degradation” policy.
  • Unknown kind strings now also surface as Skipped entries when the JSON entry carries an id. Reason: unsupported("unknown JSON kind: …"). Stage: additive. Without this, typos in kind and version-drift schemas were silently swallowed; now they’re visible. Entries without an id continue to be silently ignored, matching the rest of FeatureReconstructor (the kernel only records skips when there’s an id to attach them to).

Together these mean the inputBody → boolean(@input, slot) chain that v0.152 implies should work, actually does work end-to-end from JSON.

v0.152.0 (Apr 2026), FeatureReconstructor.inputBody for chained composition (issue #87)

FeatureReconstructor.build(from:) previously always started from an empty BuildContext.current, with the in-progress shape grown purely from additive feature entries. That blocks chaining, composing a body via one kernel API (e.g. SheetMetal.Builder from v0.151) and then cutting / finishing into it via the reconstructor. v0.152 makes the kernel itself accept a starting body.

  • Optional inputBody parameter on both build entry points: FeatureReconstructor.build(from: specs, inputBody: Shape? = nil) and FeatureReconstructor.buildJSON(_:inputBody:). When non-nil, BuildContext.current is seeded with the input and the input is registered in namedShapes under the sentinel id @input. When nil, behaviour is byte-for-byte identical to v0.151.
  • FeatureReconstructor.inputBodySentinel: the literal string @input, exposed as a public constant so JSON envelopes and Swift callers share one source of truth. Boolean leftID / rightID, Fillet.edgeSelector.onFeature, and Chamfer.edgeSelector.onFeature all resolve @input via the standard namedShapes lookup, no separate code path. Last-write-wins semantics: a feature with id == "@input" shadows the seed, which is the obvious behaviour.
  • No JSON schema change. Downstream callers using buildJSON pass inputBody: from Swift; the JSON envelope itself is unchanged. Within the envelope, references to @input are just regular id strings.
  • Stage ordering preserved. Additive features still union onto whatever current is at the start of stage 1 (input or empty). Subtractive / finishing / annotation stages run with the same dispatch as v0.151. The existing Skipped reporting (under-determined / OCCT failure / unresolved-ref / unsupported) is unchanged.

The immediate driver is the sheet-metal → reconstructor chain referenced by OCCTSwiftScripts#13: build a bent bracket via SheetMetal.Builder, then drill mounting holes into it with the reconstructor’s hole-placement and Skipped machinery. The verb-side wiring downstream is one line, FeatureReconstructor.buildJSON(envelope, inputBody: try GraphIO.loadBREP(at: path)).

This is also the primitive the planned Skipped resume-from-last-good-shape behaviour will need: “given a partially-built shape, continue applying remaining specs” reduces to an inputBody-aware build.

Out of scope: multi-body input lists (use Shape.compound upstream), round-tripping face / edge tags from prior history (gone after BREP serialisation), reverse decomposition (Shape → [FeatureSpec]).

v0.151.0 (Apr 2026), Sheet-metal composition API (issue #85)

OCCT has no sheet-metal bend primitive and is not expected to grow one. CATIA / SolidWorks / FreeCAD all compose bends from extrude + union + fillet. v0.151 adds the canonical Swift-level composition so downstream consumers (OCCTDesignLoop’s VLM reconstructor, scripts, MCP tooling) do not each reinvent it.

  • SheetMetal.Flange: a closed 2D profile positioned in world space by explicit (origin, uAxis, vAxis, normal). All three axes are independent so left-handed world placements (e.g. a flange normal along +Y with the profile reading +X / +Z) are expressible without handedness surprises. vAxis defaults to cross(normal, uAxis) when omitted.
  • SheetMetal.Bend: names two flanges + an inside radius. No geometric data; the builder resolves the seam edge from the flange placements.
  • SheetMetal.Builder.build(flanges:bends:): extrudes each flange along its normal by thickness, fuses the bodies in order, then for each bend finds the seam edge(s) and applies Shape.filleted(edges:radius:). Seam finding walks the fused shape’s edges, keeps only those parallel to cross(nA, nB), and selects the one whose midpoint lies on each flange’s face that points toward the other flange, which uniquely identifies the bend and rejects the coincidental convex back corner.
  • SheetMetal.BuildError: named cases for invalid thickness, empty flange list, duplicate/unknown IDs, invalid profile, extrusion/union/fillet failures, parallel flanges (no seam direction), and missing seam edge. CustomStringConvertible for direct logging.

Known limitation: stepped seams (flanges meeting along less than their full seam-direction extent, e.g. a narrow upright on a wider base) surface as BuildError.filletFailed. OCCT cannot cleanly round an edge that terminates at a free-face boundary; downstream callers should match flange widths along the seam or split the wider flange. Reverse-direction unwrap (bent BRep → flat cutting pattern) is the planned next addition to this namespace.

v0.150.0 (Apr 2026), Pure-Swift PDF + SVG export + BOM + balloons

Second half of the v0.149 → v0.150 drawing-automation arc. Drawings now have three readable output formats (DXF for engineering tools, PDF for humans, SVG for the web) plus the assembly-drawing primitives that make BOM-driven output a one-call operation.

  • PDFWriter + Exporter.writePDF(drawing:to:pageSize:) / writePDF(sheet:body:to:): pure-Swift PDF 1.4 writer. No UIKit / AppKit / Core Graphics dependency; works on macOS, iOS, and Linux. Helvetica font, one page per file, content stream installs a mm→pts CTM so staged geometry stays in drawing units. Per-layer ISO 128-20 stroke weights (0.5 mm VISIBLE / OUTLINE, 0.25 mm HIDDEN / CENTER / DIMENSION / TEXT, 0.18 mm HATCH) with dashed / chain patterns on HIDDEN / CENTER. Circles rendered as four cubic Bézier segments; arcs split into ≤90° Bézier chunks.
  • SVGWriter + Exporter.writeSVG(drawing:to:) / writeSVG(sheet:body:to:): pure-Swift SVG 1.1 writer. One <g> group per layer with stroke / stroke-width / stroke-dasharray attributes. Arcs emitted as native SVG <path d="M… A …"/>. ViewBox explicit or computed from content bounds. Drawing’s mathematical Y (up) mapped to SVG’s screen Y (down) via a group-level scale(1,-1); each <text> carries its own counter-transform so glyphs read right-side up.
  • DrawingAnnotation.balloon(Balloon): new case carrying itemNumber + centre + radius + optional leaderTo. Rendered in every writer (DXF / PDF / SVG) as a circle + number text + optional leader line that exits the circle at the point nearest the target. Drawing.addBalloon(itemNumber:at:leaderTo:radius:id:) is the convenience entry point.
  • BillOfMaterials: pure-Swift Codable value type. Seven-column table (ITEM / PART NO / DESCRIPTION / QTY / MAT / MASS / NOTES) with per-column default widths; caller populates [Item] and calls render(into: DXFWriter, at:). Origin is the bottom-right anchor so the table grows up and to the left (idiomatic placement above a title block). Sheet.renderBOM(_:into:at:) convenience places the BOM right-aligned to the inner frame’s top edge.
  • DrawingDispatch.swift: shared internal annotation + dimension dispatcher used by PDFWriter and SVGWriter. DrawingPrimitiveOps struct bundles the five drawing primitives (addLine / addPolyline / addCircle / addArc / addText) as closures; a single dispatch path handles every DrawingAnnotation case (centreline, centermark, textLabel, hatch, cuttingPlaneLine, balloon) and every DrawingDimension case including tolerance rendering. DXFWriter continues to use its own inline logic, not because it couldn’t be ported, but to keep its test coverage load-bearing and avoid regression risk.
  • Exporter.pdfA3Landscape / pdfA4Landscape: named pts-space page-size constants. Also PDFWriter.addDimension(_:) / SVGWriter.addDimension(_:) mirror the DXF-side method added in v0.149 for ad-hoc dimension staging without a Drawing.

After v0.150, the only substantive drawing-layer gap is native DXF DIMENSION entities (still exploded LINE+TEXT), which remains demand-gated.

v0.149.0 (Apr 2026), Sheet automation + tolerance + ordinate dimensioning

First of a two-release arc closing the last substantive drawing-automation gaps: one-call multi-view layout, typed tolerance data on every dimension, and ISO 129-1 §9.3 ordinate dimensioning.

  • Sheet.standardLayout(of:scale:margin:includeIso:): composes front / top / side / optional isometric views of a Shape onto the sheet’s inner frame as a 2x2 grid. Arrangement follows the sheet’s ProjectionAngle: first-angle places top below front, third-angle places top above. Uniform scale is computed to fit the widest projected view; callers can pass a smaller DrawingScale to override. Returns a StandardLayout whose PlacedViews hold the original Drawings (attach dimensions per view before calling render(into:)).
  • Drawing.addAutoDimensions(from:viewDirection:minRadius:dimensionOffset:bounds:): heuristic dimensioner: adds a linear dimension for the projected X and Y extents of the shape’s bounding box, plus a diameter dimension on every visible circular edge. Edge-on circles are skipped (mirrors the addAutoCentermarks detection); minRadius filters noise holes.
  • DrawingTolerance: typed, Codable enum carried as tolerance: DrawingTolerance on every DrawingDimension payload (Linear, Radial, Diameter, Angular, Ordinate). Cases: .none, .symmetric(Double), .bilateral(plus:minus:), .unilateral(Double), .fitClass(String), .limits(lower:upper:). Inline cases fold into the nominal label; multi-value cases render as stacked upper/lower TEXT in DXF at ~55% height, placed perpendicular to each dimension’s text baseline.
  • DrawingDimension.ordinate(Ordinate): shared-origin X+Y dimensioning for CNC reference-datum workflows. Each feature carries its own position plus optional custom label; a single tolerance applies across all features. DXF emit draws a small origin cross, per-feature extension lines with ticks at the origin baseline, and offset labels perpendicular to each line. Drawing.addOrdinateDimensions(origin:features:tolerance:id:) is the convenience entry point. DrawingDimension.Ordinate + Feature are Codable for JSON-driven pipelines.
  • DXFWriter.addDimension(_:): public single-entity dispatch over every DrawingDimension case; useful for tests and for scripts that compose DXFs from dimension values without going through a Drawing.

v0.148.0 (Apr 2026), Drawing.append(_:) unified dispatcher

Small release closing #83 and #84, both asked for the same thing: a public Drawing.append(_:) that dispatches every DrawingAnnotation case without the consumer-side switch blind spot.

  • Drawing.append(_ annotation: DrawingAnnotation): appends any DrawingAnnotation case (centreline, centermark, textLabel, hatch, cuttingPlaneLine). When new cases land, the dispatcher updates in one place, not in every consumer.
  • Drawing.append(contentsOf: [DrawingAnnotation]): for factory output like DrawingAnnotation.surfaceFinish(...), .featureControlFrame(...), .datumFeature(...), .breakLine(...), .cosmeticThreadSideView(...) which all return arrays.
  • Drawing.append(_ dimension: DrawingDimension) / append(contentsOf: [DrawingDimension]), symmetric for dimensions.

Downstream replay(...) helpers (OCCTSwiftScripts, OCCTSwiftPartsAgent) collapse to one-line drawing.append(contentsOf: DrawingAnnotation.surfaceFinish(...)). The existing addCentreLine / addCentermark / addTextLabel / addHatch / addCuttingPlaneLine typed factories continue to work unchanged; they’re now a thin convenience over append(_:) conceptually (though the storage path is identical either way).

v0.147.0 (Apr 2026), Drawing + FeatureSpec consumer polish

Closes four small follow-up issues (#79, #80, #81, #82) that downstream consumers (OCCTSwiftScripts, OCCTDesignLoop, MCP tooling) asked for to remove boilerplate and unblock JSON-driven workflows.

  • #80 Edge.curve3D: Direct Edge → Curve3D bridge. Ensures the 3D curve is built via BRepLib::BuildCurves3d for pcurve-only edges. Returns the raw Geom_Curve so consumers can call curve.circleProperties / lineProperties / etc. without DownCast gymnastics.
  • #79 Drawing.addAutoCentermarks(from:viewDirection:extent:minRadius:bounds:): symmetric to addAutoCentrelines. Walks circular edges, projects each centre into the view plane, adds .centermark annotations. Skips edges whose circle plane is parallel to the view (edge-on). minRadius filters small holes; bounds filters centermarks outside the view.
  • #81 DrawingAnnotation.CuttingPlaneLine + Drawing.addCuttingPlaneLine: typed ISO 128-40 cutting-plane line. Computes trace in view 2D from cutting plane normal × view direction. DXFWriter renders heavy-chain ends, thin-chain middle, perpendicular arrows, and label letters at both ends.
  • #82 FeatureSpec Codable conformance: FeatureSpec + all nested types (Revolve, Extrude, Hole, Thread, EdgeSelector, Fillet, Chamfer, Boolean) now Codable. Unblocks FeatureReconstructor.buildJSON + Python / MCP driven reconstruction pipelines without each consumer mirroring the types in their own schema.

v0.146.0 (Apr 2026), ISO drawings III: cosmetic threads, surface finish, GD&T symbols, detail views

Closes the ISO drawings arc (#78). Final release ships cosmetic threads (#77), ISO 1302 surface finish, ISO 1101 GD&T symbols, and compressed-view conventions (detail + break lines).

  • #77 DrawingAnnotation.cosmeticThreadSideView / cosmeticThreadEndView: ISO 6410 cosmetic thread representation. Side view: two parallel lines at minor diameter spanning the thread length, optional callout text. End view: 3/4 broken arc set (0–90° / 90–180° / 180–315° with a 45° gap). Drawing.addCosmeticThreadSide(...) and DXFWriter.addCosmeticThreadEndView(...) convenience wrappers.
  • ISO 1302 surface finish: SurfaceFinishSymbol enum (.any / .machiningRequired / .machiningProhibited). DrawingAnnotation.surfaceFinish(at:leaderTo:ra:symbol:method:) produces the check-mark geometry with Ra value label, horizontal bar for machiningRequired, optional production-method text, and leader line to the target feature.
  • ISO 1101 GD&T symbols: GDTSymbol enum covering all 15 ASME/ISO geometric characteristics (straightness, flatness, circularity, cylindricity, profile of line/surface, perpendicularity, parallelism, angularity, position, concentricity, symmetry, coaxiality, circular runout, total runout). DrawingAnnotation.featureControlFrame(at:symbol:tolerance:datums:leaderTo:) emits the classic [⌖] [0.1] [A] [B] [C] rectangular frame. DrawingAnnotation.datumFeature(label:at:pointingTo:) emits the boxed letter + triangle pointer.
  • Detail views: Drawing.detailView(at:scale:) returns a TransformedDrawing suitable for placing a scaled-up region of the parent drawing at a specific sheet location.
  • Break lines: DrawingAnnotation.breakLine(from:to:amplitude:) emits ISO 128-30 compressed-length zigzag marker as 5 line segments.

v0.145.0 (Apr 2026), ISO drawings II: sheet templates, title blocks, projection symbols

Second release in the ISO drawings arc (#78). Closes #76, adds ISO 5457 trimmed-sheet templates, ISO 7200 title blocks, and ISO 5456-2 projection symbols as first-class OCCTSwift API.

  • PaperSize: A0 / A1 / A2 / A3 / A4 with .size(in: .landscape) / .portrait returning ISO 5457 trimmed dimensions in mm.
  • Orientation: .landscape / .portrait.
  • ProjectionAngle: .first (ISO / Europe) / .third (ANSI / USA).
  • TitleBlock: ISO 7200 mandatory + optional fields (title, drawingNumber, owner, creator, approver, documentType, dateOfIssue, revision, sheetNumber, language, material, weight, scale).
  • Sheet: ties PaperSize + Orientation + ProjectionAngle + TitleBlock together. render(into: DXFWriter) emits border + ISO 5457 inner frame with correct margins (20 mm binding left, 10 mm other edges on A0–A3), centring marks at edge midpoints, and the title block in the bottom-right. innerFrame property exposes the drawable rectangle for layout.
  • ProjectionSymbol: ProjectionSymbol.render(.first, at:, into:) emits the ISO 5456-2 truncated-cone + circle pair at the correct relative position for first / third angle.
  • DXFWriter gets two new layers: BORDER and TITLE.

v0.144.0 (Apr 2026), ISO drawings I: section views, hatch, multi-view, style foundations

First of a three-release ISO-drawings arc (tracked in #78). Closes #73, #74, #75 and adds the ISO 128-20 / 3098 / 5455 style primitives every downstream sheet producer needs.

  • #75 Drawing.transformed(translate:scale:) + Drawing.bounds: new TransformedDrawing wrapper and DXFWriter.collectFromDrawing(_ transformed:) overload. Drawing.bounds(deflection:includeAnnotations:) returns the drawing’s 2D axis-aligned bounding box. Unblocks multi-view sheet composition: writer.collectFromDrawing(view.transformed(translate: offset, scale: 0.5)).
  • #73 Shape.section2D(planeOrigin:planeNormal:planeU:deflection:) + Shape.section2DView(...): slice a shape with a plane, return a Drawing in the plane’s own 2D frame (not world space). section2DView wraps the contour with automatic ISO 128-40 hatching at 45° and an optional “A-A” label.
  • #74 Drawing.addHatch(boundary:angle:spacing:islands:): ISO 128-50 sectional-view fill. DXFWriter tessellates into line segments at the specified angle and spacing with island (hole) subtraction via even-odd rule scanlines. Adds HATCH + SECTION XCAF layers.
  • G1 ISO 128-20 line widths + ISO 128-21 arrows + ISO 3098 text heights: DrawingLineWidth enum (w013 → w200, ISO 1:1.4 series), DrawingTextHeight enum (h25 → h200) with .recommended(forPaper:) and .snap(_:), DrawingArrowStyle (filledClosed / openClosed90 / openClosed30 / tick), DrawingLineStyle.defaultWidth / .boldWidth per style.
  • G2 ISO 5455 DrawingScale: enum cases .one / .reduction(Int) / .enlargement(Int) / .custom(Double) with .factor and .label accessors. DrawingScale.preferred returns the ISO-standard scale series (50:1 down to 1:1000).

v0.143.0 (Apr 2026), Measurement ergonomics + clearing v0.142 deferrals

Small-but-broad release that sands the measurement papercuts surfaced by the v0.143 audit and retires every deferral the v0.142 release notes flagged. Roughly 40 ops: 4 measurement additions, 5 deferral clearings.

Measurement ergonomics (M1–M4):

  • Shape.volume / Shape.surfaceArea: verified already wrapped as optional properties (audit had missed them); no new code, just confirmation.
  • Curve3D.distance(to: SIMD3) / Edge.distance(to: SIMD3): one-liner point-to-curve distance when you don’t need the projected point / parameter.
  • Angle helpers: Edge.angle(to:), Edge.isParallel(to:tolerance:), Edge.isPerpendicular(to:tolerance:), Face.angle(to:), Face.isParallel(to:), Face.isPerpendicular(to:), Face.isCoplanar(with:tolerance:). Plus ConstructionAxis.angle(to:in:), ConstructionPlane.angle(to:in:). unsignedAngle(between:and:) free function for SIMD3 pairs.
  • Circle / revolution property extraction: Edge.circleProperties returns (center, radius, axis, isFullCircle, startAngle, endAngle)? for circular edges (three-point circle fit). Face.revolutionProperties returns (axis, radius)? for cylindrical / conical / spherical / toroidal / surface-of-revolution faces.

Deferral clearings (from v0.142 release notes):

  • Constructionspeak persistence (D1): Document.addConstructionShape(_:) tags a shape with the CONSTRUCTION XCAF layer; Document.constructionShapeLabels enumerates on reload. ConstructionContext.materialize(in:graph:options:) resolves every plane/axis/point recipe and creates a finite representative shape (rectangular face for planes, bounded edge for axes, vertex for points) on the layer. STEP export preserves layer tags; import produces layer-marked shapes but not the typed recipes. Matches FreeCAD’s long-standing ceiling.
  • Arc / circle tessellation in Sketch.buildProfile (D2): SketchElement.CurveKind.tessellate2D(segmentsPerRadian:) for all four curve kinds (line / polyline / arc / circle). Sketch.buildProfile now lifts tessellated samples through the host plane’s frame. D-shaped and circular profiles now produce wires.
  • Named-shape registry for FeatureSpec.Boolean (D3): Each feature with a non-nil id registers its produced shape in an internal dict; Boolean.leftID / rightID look up by id. .union / .subtract / .intersect all supported. Missing-id cases report .unresolvedRef.
  • Multi-leaf .createdBy disambiguation (D4): new leafOccurrence: Int? = 0 parameter on TopologyRef.createdBy, pick the Nth leaf when a creation has split into multiple live descendants. TopologyGraph.currentForms(of:) returns all leaves. leafOccurrence: nil disables forward-walk.
  • FeatureReconstructor ↔ TopologyGraph coupling for EdgeSelector (D5): .nearPoint(point, tolerance) resolves edges by midpoint-distance within the target shape. .onFeature(featureID) looks up the source feature’s shape via the named-shape registry and heuristically matches target edges whose midpoints coincide with the source’s edges. .all for uniform fillet/chamfer still works. (v1 heuristic; full graph-history dispatch remains available if consumers need per-op edge identity.)

Scope cuts: chamfer per-edge selector still requires a per-edge distance array the bridge doesn’t yet expose, falls through to .unsupported for .nearPoint / .onFeature on chamfer specifically. Uniform chamfer (.all) works. Flagged as a v0.144 candidate.

v0.142.0 (Apr 2026), Construction geometry, sketches, FeatureReconstructor

Second release in the v0.141 → v0.143 arc, ships Phases 2–6 from #72 plus #62 in one go. With this release, OCCTSwift has the full construction-geometry vocabulary that agentic modelling needs: recipe-based references (v0.141) → typed construction entities → document context → sketches → declarative feature reconstruction.

  • ConstructionPlane / ConstructionAxis / ConstructionPoint (#72 Phase 2): Fusion-style recipe enums carrying TopologyRefs. 7 plane variants (absolute, offsetFromFace, throughAxis, tangentToFace, midPlane, byThreePoints, normalToEdge), 5 axis variants (absolute, alongEdge, normalToFace, throughPoints, intersectionOfPlanes), 6 point variants (absolute, atVertex, midpointOfEdge, centroidOfFace, atEdgeParameter, intersectionOfAxisAndPlane). Resolvers compute Placement / (origin, direction) / SIMD3<Double> against a TopologyGraph. Typed ConstructionResolutionError.
  • TopologyRef.containedIn now resolves (#72 Phase 2 unblock): new OCCTBRepGraphChildIndices bridge + TopologyGraph.childIndices(rootKind:rootIndex:targetKind:) Swift wrapper.
  • ConstructionContext (#72 Phase 3): Document-level collection with typed opaque IDs (PlaneID / AxisID / PointID), named entities, per-entity resolution against a graph, and allBroken(in:) diagnostic returning every entity that fails to resolve. Document.constructionContext is a lazy per-document property.
  • Sketch + SketchElement (#72 Phase 4): Sketch is hosted on a ConstructionPlane ID, carries an array of SketchElements with per-element isConstruction flag. buildProfile(in:graph:) is the single filter site (FreeCAD-inspired), construction elements are excluded when assembling the profile wire. Elements: .line, .polyline, .arc, .circle (arcs/circles tessellation comes later).
  • FeatureReconstructor (#62): Declarative FeatureSpec tagged union (revolve / extrude / hole / thread / fillet / chamfer / boolean). FeatureReconstructor.build(from:) with staged additive → subtractive → finishing → annotation dispatch. EdgeSelector enum with .all, .nearPoint, .onFeature, .onFeature currently reports .unsupported pending full TopologyGraph-integrated dispatcher; .all works today for uniform fillet/chamfer. FeatureReconstructor.buildJSON(_:) front end parses the OCCTDesignLoop-compatible schema.
  • Placement shared value type (origin + orthonormal basis) with ergonomic init(origin:normal:) that picks deterministic x/y axes.

Scope of what the v1 implementation deliberately does not do (deferred to later iterations as concrete consumers surface):

  • Constraint solving in Sketch, explicit non-goal (see #72).
  • Named-shape registry for FeatureSpec.Boolean with id-based left/right selection.
  • .onFeature / .nearPoint edge resolution in fillet/chamfer dispatch, requires coupling FeatureReconstructor to a live TopologyGraph, which is the natural next iteration once agents drive it.
  • XCAF CONSTRUCTION layer persistence, recipes live in-memory; STEP round-trip drops them (matches FreeCAD’s 20-year limitation documented in #72).
  • Multi-leaf .createdBy disambiguation when a single creation splits into many live descendants.

v0.141.0 (Apr 2026), Construction-geometry foundation: BRepGraph history readback + TopologyRef

First release in the v0.141 → v0.143 “Construction Geometry” arc (tracked in #72). Builds the substrate for recipe-based topology references that survive mutations, the prerequisite for agent-driven CAD where construction planes / axes / points stay attached to model features through edits.

  • BRepGraph history record readback (#72 Phase 0): Exposes the old→new node mappings that the OCCT kernel was already recording. TopologyGraph.historyRecord(at:), .historyRecords, .findOriginal(of:), .findDerived(of:), .recordHistory(operationName:original:replacements:). New TopologyGraph.NodeRef value type (kind + index) and HistoryRecord with full mapping.
  • TopologyRef recipe type (#72 Phase 1): Indirect enum expressing topology references as recipes evaluated against the current graph, not as indices (Onshape FeatureScript-inspired). Cases: .literal(NodeRef), .createdBy(operationName:kind:occurrence:), .containedIn(parent:kind:occurrence:), .splitOf(original:occurrence:). Typed TopologyResolutionError enum for failure modes.
  • TopologyGraph.resolve(_:): Evaluates recipes by walking history records, returns Result<NodeRef, TopologyResolutionError>. .createdBy picks up newly-introduced replacements by operation name and walks forward to the current form; .splitOf picks the Nth replacement of a split original; ancestor-resolution failures surface as .ancestorMissing.

Scope: .containedIn returns .noCurrentDescendant until Phase 2 adds child-at-index accessors. .createdBy current-form walk picks the first leaf in deterministic order; multi-leaf disambiguation (useful when a single creation splits into many live descendants) comes in later phases.

v0.140.0 (Apr 2026), GD&T write path + typed dimension/tolerance enums

Completes the read-only GD&T support shipped in v0.21.0 with a write path. Downstream callers can now author XCAFDoc_Dimension / XCAFDoc_GeomTolerance / XCAFDoc_Datum attributes, attach them to shape labels, and round-trip through STEP AP242. Typed Swift enums replace the raw Int32 type codes from v0.21.0 for the full list of XCAFDimTolObjects types.

  • Typed enums: Document.DimensionType (all 32 XCAFDimTolObjects_DimensionType cases, Location_Linear, Size_Diameter, Size_Radius, toroidal variants, etc.) and Document.GeomToleranceType (all 16, flatness, perpendicularity, position, profileOfLine, etc.).
  • Typed value types: Document.Dimension, Document.GeomTolerance, Document.Datum. Accessors: typedDimension(at:), typedGeomTolerance(at:), typedDatum(at:), typedDimensions, typedGeomTolerances, typedDatums.
  • Write path: Document.createDimension(on:type:value:lowerTolerance:upperTolerance:), createGeomTolerance(on:type:value:), createDatum(name:), setDimensionTolerance(at:lower:upper:). Returns the new attribute’s index or nil on failure.
  • Bridge additions: OCCTDocumentCreateDimension, OCCTDocumentCreateGeomTolerance, OCCTDocumentCreateDatum, OCCTDocumentSetDimensionTolerance.

Scope: full modifier / qualifier / grade sequences (XCAFDimTolObjects_DimensionModif, GeomToleranceModif, DatumSingleModif etc.) remain partial wrapping, added on demand. This release covers the 90%-case authoring path.

v0.139.0 (Apr 2026), Thread Form v2 + cleanup

Replaces v0.138’s circular-sweep thread placeholder with a real truncated V-profile following ISO-68 / UN conventions. Also folds in two quality-of-life cleanups (#68 boolean arg labels, #69 versioned MARK headers).

Behaviour change: callers of v0.138’s Shape.threadedHole / threadedShaft will now receive geometry that actually looks like a thread in HLR reprojection (alternating diagonal edges at pitch spacing) rather than a helical groove. API signatures unchanged; new default parameters (starts: 1, runout: .none) preserve single-start no-runout behaviour.

  • Thread Form v2 (#66 follow-up): ThreadCutterProfile builds a truncated trapezoidal cross-section with 30° flanks (60° included), H/8 crest flat, H/4 root flat. Swept along a helical spine with BRepOffsetAPI_MakePipeShell (correctedFrenet mode) and boolean-cut against the target. New crestFlat / rootFlat / minorDiameter accessors on ThreadSpec. New RunoutStyle enum (.none / .filleted(radius:) / .tapered(turns:)). New starts: Int parameter on threadedHole / threadedShaft for multi-start threads.
  • Boolean op labels (#68): Shape.union(_:), Shape.intersection(_:), Shape.section(_:) now match Shape.subtracting(_:), all unlabelled, consistent with Set.union(_:) / Set.intersection(_:). Deprecated with:-labelled shims kept for backwards compatibility.
  • MARK header refactor (#69): 32 versioned grab-bag MARK headers (// MARK: - v0.X.Y: A, B, C) renamed to feature-first format (// MARK: - A, B, C (v0.X.Y)). Xcode jump-to-section and grep-for-feature now work; OCCTMCP’s MARK-based API-reference generator can categorise without a regex fallback.

Tapered-runout law-based pipe-shell is tracked as a follow-up, the .tapered case falls back to .filleted until BRepOffsetAPI_MakePipeShell::SetLaw is wrapped.

v0.138.0 (Apr 2026), Engineering Drawings II: DXF export + thread features

Second release in the v0.137 → v0.139 arc. Closes #63 (DXF export) and #66 (ISO thread features). ~50 ops.

  • DXF 2D writer (#63): Custom pure-Swift DXF R12 ASCII writer (OCCT ships no DXF support, confirmed by audit). Exporter.writeDXF(drawing:to:deflection:) walks a Drawing’s visible / hidden / outline edges through Shape.allEdgePolylines and emits LINE / LWPOLYLINE / CIRCLE / ARC / TEXT entities. Layers: VISIBLE / HIDDEN / OUTLINE / CENTER / DIMENSION / TEXT, with appropriate linetypes (CONTINUOUS / DASHED / CHAIN). Dimensions from v0.137’s DrawingDimension are emitted as exploded LINE+TEXT geometry (universally readable). Exporter.writeDXF(shape:to:viewDirection:) convenience combines projection and write. Public DXFWriter for callers composing DXF manually.
  • Thread features (#66): ThreadForm enum (iso68 / unified); ThreadSpec struct with parse("M5x0.8"), parse("1/4-20 UNC"), metric-coarse-pitch table, theoretical and cut depth accessors, minor-diameter computation. Shape.threadedHole(axisOrigin:axisDirection:spec:depth:) and Shape.threadedShaft(axisOrigin:axisDirection:spec:length:) produce helical cut / boss geometry via BRepOffsetAPI_MakePipeShell sweep of a circular profile. Integrates with #62’s FeatureReconstructor, FeatureSpec.Thread can now route through real geometry instead of annotation-only.

Scope decisions: v1 threads use a circular sweep cross-section rather than full 60° flank triangle, produces correct handedness, pitch, diameter, and depth for reprojection diff and visualisation; manufacturing-accurate flanks land in a follow-up release. Multi-start threads, ACME / BSP / NPT forms, and full BRepOffsetAPI_MakePipeShell option wrapping (SetForceApproxC1, multi-profile Add()) deferred. GLTF Shape-level export, PLY import, STEP/IGES option completeness dropped from v0.138, Document-level GLTF already ships, and the remaining gaps are low priority vs. closed-loop pipeline needs.

v0.137.0 (Apr 2026), Engineering Drawings I: axes, dimensions, centrelines

Keystone release for the v0.137 → v0.139 “Engineering Drawings” series (tracked in #67). Adds axis extraction from shapes (#65), a pure-Swift value-type dimensioning API on Drawing (#64), and auto-centreline generation bridging the two. ~60 ops.

  • Axis extraction (#65): Face.primaryAxis, Shape.revolutionAxes(tolerance:), Shape.symmetryAxes(fractionalTolerance:), Surface.torusAxis, Surface.revolutionAxis. New ShapeAxis value type with .cylinder/.cone/.sphere/.torus/.revolution/.extrusion/.symmetry kinds. Bridge: OCCTSurfaceTorusAxis, OCCTSurfaceRevolutionAxis, OCCTSurfaceRevolutionLocation, OCCTFaceGetPrimaryAxis, OCCTShapeRevolutionAxes, OCCTShapeSymmetryAxes.
  • Surface introspection completeness: typed Surface.SurfaceType + Surface.surfaceKind; Surface.Continuity + Surface.continuityClass; type-predicate conveniences isPlane / isCylinder / isCone / isSphere / isTorus / isBezier / isBSpline / isSurfaceOfRevolution / isSurfaceOfExtrusion / isOffsetSurface.
  • Drawing dimensioning API (#64): DrawingDimension tagged union (linear / radial / diameter / angular) + DrawingAnnotation tagged union (centreline / centremark / text label). DrawingLineStyle enum. Methods on Drawing: addLinearDimension, addRadialDimension, addDiameterDimension, addAngularDimension, addCentreLine, addCentermark, addTextLabel, clearAnnotations, plus dimensions / annotations accessors. Pure-Swift value types. XDE round-trip deferred to v0.139 (#67).
  • Auto-centreline generation (#64 ↔ #65): Drawing.addAutoCentrelines(from:viewDirection:overshoot:tolerance:bounds:) projects a shape’s revolution axes into the drawing’s view plane and emits chain-pattern centrelines; axes parallel to the view direction are returned in .skipped.

Scope decisions (see #67 for rationale): Full PrsDim display-dimension completeness (MaxRadius / MinRadius / Chamf2d / Chamf3d) and PrsDim geometric-relation wrapping (Concentric / Parallel / etc.) were cut from v0.137, they are AIS display objects with low marginal value compared to the Swift value-type API that drives the closed-loop drawing workflow.

v0.132.0 - v0.136.0 (Apr 2026), BRepGraph Topology Graph

Wraps OCCT’s new BRepGraph API, graph-based B-Rep topology with cache-friendly traversal, O(1) upward navigation, and parallel geometry extraction. 163 operations across 5 releases.

  • v0.136.0: ML-friendly graph export (COO adjacency, node features, JSON), UV-grid face sampling (positions/normals/curvatures), edge curve sampling, for GNN/UV-Net/BRepNet pipelines
  • v0.135.0: Builder mutations. AddVertex/Shell/Solid, AddFaceToShell/ShellToSolid, AddCompound, RemoveNode/Subgraph, AppendShape, deferred invalidation, SplitEdge, ReplaceEdgeInWire
  • v0.134.0: Product/Occurrence assembly queries, RefsView per-kind counts and entry access, edge start/end vertices, shell closure, compound hierarchy
  • v0.133.0: Shape reconstruction from graph nodes, BRepGraph_Tool vertex/edge/face geometry access, CoEdge half-edge queries, history tracking, graph copy/transform, poly counts
  • v0.132.0: Core graph, build from shape, topology/geometry counts, face adjacency, shared edges, edge boundary/manifold, child/parent explorers, validate, compact, deduplicate, stats

v0.129.0 - v0.131.0 (Apr 2026), RC5 New APIs

  • v0.131.0: Approx_BSplineApproxInterp, GeomEval TBezier/AHTBezier curves+surfaces, GeomAdaptor_TransformedCurve
  • v0.130.0: GeomEval analytical curves (helix, sine wave), analytical surfaces (ellipsoid, hyperboloid, paraboloid, helicoid), Geom2dEval spirals, GeomFill_Gordon, PointSetLib, ExtremaPC
  • v0.129.0: IGES mutex serialization (thread safety fix per OCCT #1179)

v0.120.0 - v0.128.0 (Apr 2026), Completion & Polish

Final method-level coverage of all user-facing OCCT classes.

  • v0.128.0: v0.128.0 release (3333 ops total)
  • v0.125.0: BSplineSurface deep (20), Geom2d_BSpline (20), BezierCurve (8), BezierSurface (12)
  • v0.124.0: ChamferBuilder (20), FilletBuilder (16), WireAnalyzer (18)
  • v0.123.0: ThruSections/CellsBuilder/PipeShell/UnifySameDomain/Section extensions
  • v0.122.0: WireFixer, ShapeFix_Edge, BRepTools/BRepLib statics, History, Sewing extensions
  • v0.121.0: GLTF import/export (xcframework rebuilt with RapidJSON), FilletBuilder, ChamferBuilder
  • v0.120.0: IsCN, ReversedParameter, ParametricTransformation, gp extras, surface reversed copies

v0.110.0 - v0.119.0 (Mar-Apr 2026), Constraint Solvers & Serialization

  • v0.119.0: BREP serialization, gp_Pln/gp_Lin distance/contains, BezierSurface queries
  • v0.118.0: BRepBndLib, ShapeAnalysis tolerance, BRepAlgoAPI_Check/Defeaturing
  • v0.116.0: Helix construction, gp_Ax3/GTrsf2d/Mat2d, quaternion interpolation
  • v0.115.0: Interpolation expansion, ThruSections builder, Triangulation queries
  • v0.114.0: TopoDS_Builder, ShapeContents, FreeBoundsProperties, WireBuilder
  • v0.113.0: MakeEdge completions, multi-result projections, DistShapeShape full results
  • v0.112.0: RWMesh iterators, Intf_Tool, BRepAlgo_AsDes, BiTgte, wire/shell construction
  • v0.111.0: PSO, GlobOptMin, FunctionRoots, GaussIntegration, BRepLProp
  • v0.110.0: Constraint solver infrastructure. C callback adapters for OCCT math solvers

v0.100.0 - v0.109.0 (Mar 2026), Geometry Factories & Extrema

  • v0.109.0: Extrema elementary distances, TrigRoots, IntAna2d, BRepAlgo_NormalProjection
  • v0.108.0: Complete Geom_ and Geom2d_ method coverage, all conic/surface property methods
  • v0.107.0: BSpline manipulation (3D/2D/surface), Bezier methods, BRepTools, Sewing, Hatch
  • v0.106.0: GC surface factories, ShapeAnalysis_Wire/Edge, BRepLib_MakeEdge2d
  • v0.105.0: GC/GCE2d geometry factories, GCPnts uniform sampling, CompCurveToBSpline (90 ops)
  • v0.104.0: BndLib analytic bounding, OSD_Host/PerfMeter, IntAna_IntQuadQuad
  • v0.103.0: gce transform factories, GProp element properties, Plate constraints
  • v0.102.0: TopExp adjacency, Poly_Connect mesh adjacency, BRepOffset_Analyse
  • v0.101.0: Geom_TrimmedCurve, BRepLib_FindSurface, ShapeAnalysis_Surface, Resource_Manager
  • v0.100.0: RWStl I/O, ShapeAnalysis_Curve statics, BRepExtrema_SelfIntersection

v0.90.0 - v0.99.0 (Mar 2026), OCAF Extensions & Math

  • v0.99.0: Convert_CompBezierCurves, Geom_OffsetSurface, OSD_File, ShapeFix_Wireframe
  • v0.98.0: IntAna analytic intersections, OSD_Chronometer/Process, Draft_Modification
  • v0.97.0: BRepAlgo_Loop, Bnd_BoundSortBox, BRepGProp_Domain, TNaming_Naming, Precision
  • v0.96.0: XCAFDoc_AssemblyItemRef, BRepAlgo_Image, OSD_Path, BRepClass_FClassifier
  • v0.95.0: Convert ellipse/hyperbola/parabola/cylinder/cone/torus to BSpline
  • v0.94.0: math_Matrix/Gauss/SVD/PolynomialRoots/Jacobi, Convert circle/sphere to BSpline
  • v0.93.0: OSD_MemInfo, ShapeFix_EdgeProjAux, Geom2dAPI_Interpolate, BRepAlgo_FaceRestrictor
  • v0.92.0: Bnd_OBB, Bnd_Range, BRepClass3d point-in-solid, TDataXtd_Constraint
  • v0.91.0: ElCLib curve evaluation, ElSLib surface evaluation, gp_Quaternion, OSD_Timer
  • v0.90.0: TDF_ChildIDIterator, TDocStd_PathParser, TFunction_DriverTable, TNaming extensions

v0.80.0 - v0.89.0 (Mar 2026), Extrema, Color Science & OCAF Deep

  • v0.89.0: TDF_Transaction/Delta, TDF_ComparisonTool, TDocStd_XLinkTool
  • v0.88.0: TNaming extensions, TDataStd_IntPackedMap, TDataStd_NoteBook
  • v0.87.0: TDataStd_Tick/Current, ShapeAnalysis_Shell, CanonicalRecognition
  • v0.86.0: TDataStd extended attributes (BooleanArray, ByteArray, IntegerList, etc.)
  • v0.85.0: UnitsAPI, BinTools binary I/O, Message_Messenger/Report
  • v0.84.0: VrmlAPI_Writer, TDataStd_Directory/Variable, TDocStd_XLink
  • v0.83.0: XCAFDoc attributes, Notes, ClippingPlaneTool, AssemblyGraph (97 ops)
  • v0.82.0: Quantity_Period/Date, Font_FontMgr, Image_AlienPixMap (39 ops)
  • v0.81.0: Quantity_Color, Quantity_ColorRGBA, Graphic3d materials (24 ops)
  • v0.80.0: Extrema 3D/2D, GeomTools persistence, ProjLib, gce factories (35 ops)

v0.70.0 - v0.79.0 (Mar 2026), TKBool, TKFillet, TKHlr & Geometry Deep

  • v0.79.0: Poly_CoherentTriangulation, BRepFill_Evolved, BRepExtrema_DistanceSS, GeomFill
  • v0.78.0: BRepTools modifications, ShapeUpgrade_SplitSurface, GeomConvert, Poly_Polygon
  • v0.77.0: GeomLib utilities, GccAna circle/line solvers, Approx_SameParameter
  • v0.76.0: Geom_CartesianPoint, Geom_Direction, Axis1/2Placement, ShapeConstruct_Curve (41 ops)
  • v0.75.0: BiTgte_Blend, GeomConvert_ApproxCurve/Surface, GCPnts, BRepGProp
  • v0.74.0: TKMesh/TKOffset/TKPrim/TKShHealing/TKTopAlgo gap closure
  • v0.73.0: Extended HLR edges, HLRAppli_ReflectLines, Intrv_Interval (29 ops)
  • v0.72.0: LocOpe_Gluer, ChFi2d_Builder/ChamferAPI/FilletAPI, FilletSurf_Builder
  • v0.71.0: IntTools_BeanFaceIntersector, BOPAlgo_WireSplitter, BRepFeat_SplitShape
  • v0.70.0: IntTools EdgeEdge/EdgeFace/FaceFace, BOPAlgo BuilderFace/BuilderSolid

v0.60.0 - v0.69.0 (Mar 2026), Data Exchange & TKGeomAlgo

  • v0.69.0: NLPlate G2/G3, Plate_Plate solver, GeomPlate, GeomFill Generator (20 ops)
  • v0.68.0: TopTrans_CurveTransition, GeomFill trihedrons, GccAna_Circ2d3Tan (18 ops)
  • v0.67.0: FairCurve, LocalAnalysis, TopTrans SurfaceTransition (8 ops)
  • v0.66.0: Full TkG2d. Point2D, Transform2D, AxisPlacement2D, Vector2D (44 ops)
  • v0.65.0: BOPAlgo RemoveFeatures/Section, ShapeBuild, ShapeExtend, ShapeUpgrade (24 ops)
  • v0.64.0: ProjLib, BRepOffset_Offset, Adaptor3d_IsoCurve (9 ops)
  • v0.63.0: GeomLProp, BRepOffset_SimpleOffset, GeomInt_IntSS, Contap_Contour (17 ops)
  • v0.62.0: BRepLib topology, MakeEdge2d, ShapeCustom, LocOpe, CPnts (22 ops)
  • v0.61.0: Approx, Contap, BOPAlgo, IntCurvesFace, BRepMesh, GeomPlate (19 ops)
  • v0.60.0: XDE/XCAF Full Coverage (42 ops)

v0.50.0 - v0.59.0 (Feb-Mar 2026), OCAF & Data Exchange

  • v0.59.0: IGES/OBJ/PLY Full Coverage (23 ops)
  • v0.58.0: STEP Full Coverage (25 ops)
  • v0.57.0: OCAF Persistence (17 ops)
  • v0.56.0: TDataXtd + TFunction (29 ops)
  • v0.55.0: TDataStd Attributes (25 ops)
  • v0.54.0: TDF Core + TDocStd (31 ops)
  • v0.50.0-v0.53.0: Various additions

v0.38.0 - v0.49.0 (Feb 2026), Audit & Gap Closure

Systematic OCCT test suite audit rounds (7 rounds total), closing gaps in primitives, sweeps, booleans, modifications, healing, measurement, and topology.

v0.27.0 - v0.37.0 (Feb 2026), RC4 Upgrade & Feature Expansion

  • OCCT 8.0.0-rc3 → rc4 upgrade
  • Feature-based modeling, pattern operations, shape editing
  • Topological naming (TNaming), OCAF framework
  • TDataStd/TDataXtd attributes, TFunction framework

v0.16.0 - v0.26.0 (Feb 2026), Parametric Geometry

  • 2D/3D parametric curves (Geom2d, Geom) with Metal draw methods
  • Parametric surfaces with curvature analysis
  • Law functions for variable-section sweeps
  • Medial axis transform
  • Camera, selection, presentation mesh
  • Color science, materials

v0.6.0 - v0.15.0 (Jan 2026), XDE & Annotations

  • XDE document support (assembly, colors, materials, GD&T)
  • Annotations (dimensions, text labels, point clouds)
  • KD-tree spatial queries
  • Polynomial solver, hatch patterns

v0.1.0 - v0.5.0 (Dec 2025 - Jan 2026), Foundation

  • Basic primitives, booleans, transforms
  • Wire creation, sweep operations
  • Mesh generation, STL/STEP import/export
  • Shape validation and healing
  • STEP optimization