Files
ocp/docs/design.md
stroblme 9f86845c25 Add bulk UV sampler, TKHelix and MakeSphere (8.0.1.2)
- 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
2026-08-11 16:26:20 +02:00

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 --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.