- 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
206 lines
10 KiB
Markdown
206 lines
10 KiB
Markdown
# 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<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](../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.
|