- n3xd_ocp.sample.face_grid: a face's UV grid of points and outward normals in one GIL-free call, sampling exactly where np.linspace does so a caller's fitted surface does not move. - n3xd_ocp.helix: OCCT 8.0's TKHelix, which upstream does not bind at all. Takes Python lists rather than NCollection_Array1. Two things the header does not say, both found by probing: SetParameters wants N+1 diameters for N segments (one per boundary, so a taper interpolates), and the builder is right-hand only -- a negative pitch is error status 12, not a mirrored helix. - Bind BRepPrimAPI_MakeSphere and give inventory.py an EXTRA_SYMBOLS addendum for symbols no app source imports. assay's gen_flow_fixtures has been unrunnable since the 10C cutover for want of it; the gap was wider than --check, since sigdiff is inventory-driven too. Gates: 105 tests, 139/139 symbols, sigdiff clean, ASAN clean, wheel self-contained with no libGL/libX11 DT_NEEDED. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HbTQ2HYWQwdtGmGJwypt6Z
10 KiB
Design record
Notes on how this binding is built and why, for anyone touching the machinery. Adding a new class instead? adding-symbols.md is the practical guide.
What this is
A hand-written nanobind binding
covering the subset of OCCT actually in use, not the whole kernel — run
tools/inventory.py for the current count. It installs as a top-level OCP,
a drop-in replacement for cadquery-ocp-novtk.
Binding call overhead was never the bottleneck — the real work happens inside the C++ kernel — so the payoff is version velocity, a smaller footprint, correctness around object ownership, and room to add APIs upstream doesn't offer (releasing the GIL, bulk array extraction).
The handle model
opencascade::handle<T> is cast by src/common/occt_handle.h, modelled on
nanobind's own stl/shared_ptr.h.
- C++ → Python: the wrapper is a non-owning nanobind instance pointing at the C++ object, plus one handle kept alive alongside it. Reusing an existing wrapper when one is already around keeps identity stable, so an object crossing the boundary repeatedly doesn't pile up references.
- Python → C++: a plain handle copy, dropped when the caster goes out of scope. No Python reference is taken — OCCT's own atomic refcount owns the object, not the Python instance — which is exactly what makes releasing the GIL safe: OCCT can copy handles on its own threads without touching the interpreter.
Transient constructors never use nb::init<>. nanobind's normal
constructor placement-news the object into the Python instance's storage,
which OCCT would later try to delete itself. Use ocp_new<T, Args...>()
(occt_transient.h) instead — it heap-allocates and returns a handle, which
is what a transient needs when something keeps it alive past the call that
created it.
The caster enforces this rather than trusting callers: a transient whose
GetRefCount() is zero isn't handle-owned, and converting it would hand OCCT
the right to free a nanobind instance. from_python refuses instead — a
TypeError beats heap corruption.
Covered by tests/test_handles.py, run both normally and under
make test-asan: wrapper identity across a round trip, refcount balance,
null-handle ↔ None, and memory growth across repeated create/destroy
cycles.
Fidelity rules
Checked against the installed stock wheel, not assumed.
__hash__ is bound; __eq__ is not. Upstream binds __hash__ (TShape ⊕
Location) and leaves __eq__ at Python's default identity comparison, so two
re-extracted copies of the same face hash equal but compare unequal. That's
intentional upstream behaviour worth keeping as-is: code that dedupes shapes
by hash and double-checks with IsSame relies on == not being
trustworthy on its own.
Sub-shapes are returned by value (OCP_RETURN_COPY) from explorers,
iterators, map lookups and history lists. A TopoDS_Shape is a small value
holding a handle to its TShape, so a copy is cheap and owns what it points
at — this rules out a class of lifetime bug where a cached wrapper outlives
the structure it was explored from.
Executing constructors are not bound. BRepAlgoAPI_* gets a default
constructor plus SetArguments/SetTools/Build — the two-argument forms
run the algorithm immediately, which invites calling Build() a second time
and running the operation twice.
_s on every static, via OCP_DEF_S. The rule is blanket, not
clash-driven — no static ships without it.
Enums use nb::is_arithmetic() + .export_values(), matching
pybind11's int comparison and module-scope members (from OCP.TopAbs import TopAbs_FACE).
Exceptions: Standard_Failure derives RuntimeError, so except RuntimeError keeps working without ever naming an OCCT class. Around twenty
concrete exception types are also bound under OCP.Standard / OCP.StdFail
and dispatched on the OCCT dynamic type, for callers that want to match by
name.
TopoDS is a namespace in OCCT, not a class. Upstream still presents it
as a class carrying the _s statics (TopoDS.Face_s(...)), so
mod_TopoDS.cpp binds an empty carrier struct under that name to match.
GIL policy
Released around calls that stay inside the kernel and can't re-enter Python:
Build/Perform, meshing, BRepCheck_Analyzer, RWStl, and every
n3xd_ocp bulk API. Held everywhere else — this is an explicit allow-list,
not a blanket policy.
STEP and IGES readers/writers are the exception. They go through the
process-global Interface_Static settings table, and IGES reading is
documented as not thread-safe. OCCT 8.0 added a thread-safety contract for
XSTEP, but it only covers one reader/writer per thread using the default
parameter set — it says nothing about a process that mutates
Interface_Static (a common way to configure units before reading). Holding
the GIL here costs nothing if imports are already serialized on the caller's
side, and keeps behaviour closer to upstream, which doesn't release the GIL
for XSTEP either.
BinTools releases the GIL for the kernel half of (de)serialisation.
Rather than bridging a streambuf that calls back into Python per chunk,
occt_stream.h reads the file-like object fully first and hands the kernel a
plain C++ stream — one extra copy of the payload, which n3xd_ocp.bintools
avoids for callers that already have bytes.
An unregistered type can't be a default argument. nanobind converts
default values to Python objects at binding time, so giving an enum this
binding doesn't register as a default (e.g. "Algo"_a = Extrema_ExtAlgo_Grad) fails the whole extension's import with a bare
std::bad_cast and no further detail. Where the default is never overridden
in practice, the fix is to leave the argument off and let OCCT apply its own
default — tools/sigdiff.py reports every place the bound surface
deliberately differs from upstream this way.
None is rejected before the caster reaches simple overloads. Handle
parameters that legitimately accept a null handle need an explicit
nb::arg("x").none(). Null returns map to None unconditionally.
Packaging
One extension (OCP/_OCP.abi3.so) registers every OCP.* submodule via
PyImport_AddModule, so import OCP.TopoDS works without a shim module per
name. Types are shared in-process for free this way, instead of leaning on
nanobind's cross-extension registry. One .cpp file per module keeps
incremental compiles cheap — only the final link step is shared.
abi3 (cp312-abi3), so bumping the Python version doesn't require a
rebuild — the tag stays at cp312 as a floor, not a target. Stub generation
works fine under abi3. If a nanobind STABLE_ABI limitation ever gets in the
way, dropping STABLE_ABI and pinning to one interpreter is the escape
hatch.
Version <occt-version>.N, asserted at configure time against the OCCT
actually found, so the version string always reflects the kernel underneath
it. Iteration builds carry a .devN suffix; the registry never allows
republishing a version.
No sdist is ever published — it can't build without the builder image,
and shipping one just invites someone to try. sdist.exclude is not the
way to enforce that: scikit-build-core also feeds it into the wheel's
package-file mapping, so excluding everything silently ships a wheel with
the compiled extension and none of the Python package.
Fork safety: importing the extension starts no threads and creates no
fork-hostile state, so forking right after import is cheap and safe. Pinned
by tests/test_forksafety.py.
The n3xd_ocp module
OCP.* stays a symbol-for-symbol drop-in so parity testing against the
stock wheel means something; anything additive lives in n3xd_ocp instead,
shipped in the same wheel and built against the same OCCT. Only the leaf
submodules are registered from C++ — creating the parent package from C++
would put a bare module in sys.modules, and a later import n3xd_ocp
would then skip its __init__.py.
Shipped today: bintools (shape ↔ bytes, byte-identical to
OCP.BinTools), measure (batched per-face area and centroid), tess
(triangulated meshes and edge polylines), sample (a face's UV grid of
points and normals) and helix (OCCT 8.0's TKHelix builder) — see their
.pyi stubs for full signatures, or the
usage examples for a quick start.
The array extractors all reproduce the Python loop they replace exactly,
including its quirks, because their callers key on them: tess keeps the id
gap left by an untriangulated face, and sample samples the way
numpy.linspace does — endpoint forced onto the bound rather than
start + (n-1)*step — so the surface a caller fits does not move.
helix is the one module with no upstream counterpart to match: TKHelix is
new in OCCT 8.0 and cadquery-ocp is still on 7.9.3, so a 1:1 surface would
be invented rather than reproduced. It takes Python lists and builds the
NCollection_Array1 internally, which also avoids binding that template for
one caller. Its parameter shape is not in the OCCT header and cost a probe to
find: for N segments SetParameters wants N pitches, N turn counts and
N+1 diameters, one per segment boundary, so consecutive diameters that
differ taper across that segment. Anything else is a
Standard_ConstructionError reading only "wrong array dimension", so the
binding checks the shape first and says what it wanted.
Matching upstream, and how that's checked
inventory.py --check answers "does the symbol exist", not "does it behave
the same" — and that gap is where a binding can do real damage silently.
tools/sigdiff.py (make sigdiff) closes it by diffing every bound
constructor and member against the stock wheel; see
adding-symbols.md for the bug it exists to catch.
Two things static analysis can't see, worth keeping in mind:
- Instance methods. A method called on an object (
vec.Reverse()) appears in no import, so--checkis blind to it;--methodsonly guesses, by tracing local variables back to their constructor. - Whether the surface actually behaves like upstream. Confidence that it
does comes from reference values recorded off the stock wheel —
measurements, per-face area and centroid, mesh counts, and the
Modified/Generated/IsDeletedhistory maps — reproduced under this build and compared exactly.