# Design record Notes on how this binding is built and why, for anyone touching the machinery. Adding a new class instead? [adding-symbols.md](adding-symbols.md) is the practical guide. ## What this is A hand-written [nanobind](https://github.com/wjakob/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` 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()` (`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 `.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](../README.md#usage) 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](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 `--check` is blind to it; `--methods` only 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`/`IsDeleted` history maps — reproduced under this build and compared exactly.