Files
ocp/docs/design.md
stroblme fab8e93a4b parity_venv.sh follows the app to 3.13; the abi3 tag stays at cp312
The wheel is cp312-abi3, so it loads on 3.13 unchanged — the pin that has to
move is the interpreter the parity venv is built with, since its whole point is
resembling what the image ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDYRXGB8tW4NE7b91g7Kdb
2026-08-11 07:24:08 +02:00

15 KiB

Design record

Decisions that are expensive to revisit, and the evidence behind them. If you are adding symbols rather than changing the machinery, read adding-symbols.md instead.

What this is

A hand-written nanobind binding of the OCCT surface the N3XD backend actually uses — 139 symbols across 48 OCP.* modules, per tools/inventory.py, not all of OCCT. It installs as a top-level OCP, so it is a drop-in replacement for cadquery-ocp-novtk and the app's 442 import sites stay untouched.

Binding call overhead was never the bottleneck — the CAD hotspots are inside the C++ kernel — so the payoff is version velocity, footprint, correctness at the ownership boundary, and the freedom to add APIs upstream cannot (GIL release, 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 stored in its keep-alive list. An existing wrapper is reused (is_new == false), so identity holds while a wrapper is alive, and a long-lived object crossing the boundary repeatedly does not pile up redundant references. Transients are polymorphic, so nb_type_put_p downcasts on the dynamic type.
  • Python → C++: a plain handle copy, balanced when the caster dies after the call. Unlike shared_ptr, no Python reference is taken: OCCT's intrusive atomic refcount owns the object's memory, not the Python instance. That is precisely what makes releasing the GIL safe — OCCT may copy handles on its own worker 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 delete. Use ocp_new<T, Args...>() (occt_transient.h), which heap-allocates and returns a handle. This is not hypothetical: the app builds a Geom_BSplineCurve in sketch_builder/edges.py and hands it to BRepBuilderAPI_MakeEdge, which keeps a handle past the call.

The caster enforces the rule rather than trusting it: a transient whose GetRefCount() is zero is not handle-owned, and converting it would hand OCCT the right to free a nanobind instance. from_python refuses instead — a TypeError beats heap corruption, and only a binding bug can reach it.

Alternative considered: nb::intrusive_ptr plus a side table mapping Standard_Transient* to PyObject* (OCCT objects have no self-py slot). It loses on the property that matters most here — nanobind's intrusive protocol unifies the C++ count with the PyObject refcount, so Py_INCREF from a GIL-free OCCT thread becomes a crash class. Kept as the documented fallback if the caster above ever proves unworkable.

Verification: tests/test_handles.py, run both normally and under make test-asan. It covers wrapper identity across a round trip, survival in both directions when one side drops its reference, refcount balance across 1000 conversions and across 100 raising calls, null-handle ↔ None, and RSS growth across 50 000 create/destroy cycles. ASAN covers memory safety; the RSS assertion covers growth and is skipped under ASAN, whose quarantine retains freed memory (139 MB of it, which is what a naive reading would call a leak).

Fidelity rules

Verified 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 one face hash equal but compare unequal. That pairing looks like a bug and is load-bearing: cad/topology/geom_memo.py buckets on hash(face) and disambiguates with IsSame because == cannot be trusted. Binding __eq__ to IsEqual would quietly collapse entries that memo keeps apart. Hash values need not match upstream — only the semantics do.

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 one incref and owns what it points at. This makes the lifetime class that segfaulted a process-global face memo unrepresentable.

Executing constructors are not bound. BRepAlgoAPI_* gets a default constructor plus SetArguments/SetTools/Build — the two-argument forms run the algorithm immediately, which is how a latent double-execution survived in the app for a while.

_s on every static, via OCP_DEF_S. The rule is blanket rather than clash-driven, so no static can ship without it; the app calls 176 of them.

Enums use nb::is_arithmetic() + export_values(), reproducing pybind11's int comparison and module-scope members (from OCP.TopAbs import TopAbs_FACE).

Exceptions: Standard_Failure derives RuntimeError, which is what keeps the backend's except RuntimeError sites working — it never names an OCCT class. About 20 concrete types are bound under OCP.Standard / OCP.StdFail and dispatched on the dynamic OCCT type, because cad_pool's children marshal failures home as f"{type(exc).__name__}: {exc}", making the name observable.

TopoDS is a namespace in OCCT 7.9, not a class. Upstream still presents it as a class carrying the _s statics, and the app calls TopoDS.Face_s(...), so mod_TopoDS.cpp binds an empty carrier struct under that name.

GIL policy

Released around calls that stay inside the kernel and cannot re-enter Python: Build/Perform, meshing, BRepCheck_Analyzer, RWStl, and every n3xd_ocp bulk API. Applied from an explicit list, never blanket.

The XSTEP readers and writers are the exception, amending what this section said before Inc 3 landed. STEP and IGES read and write through the process-global Interface_Static settings table, the IGES reader is documented as not thread-safe, and the app already serialises every import behind a lock — so holding the GIL costs nothing there and removes a whole class of question. Upstream releases nowhere, so this also stays closer to it. RWStl touches no global state and does release.

BinTools is also GIL-free for the kernel half, which the original plan assumed impossible. Rather than bridging a streambuf that calls back into Python per chunk, occt_stream.h slurps the file-like object first and hands the kernel a pure C++ stream. That is correct regardless of how BinTools seeks, and costs one extra copy of the payload — which n3xd_ocp.bintools avoids entirely for the pool paths that care.

An unregistered type cannot be a default argument. nanobind converts defaults to Python objects at binding time, so a .def(..., "Algo"_a = Extrema_ExtAlgo_Grad) for an enum this binding does not register fails the whole extension's import with a bare std::bad_cast — no file, no line. It happened three times while writing Inc 1 and 2. Where the trailing argument is one the app never overrides, the fix is to leave it off and let OCCT apply its own default: GeomAPI_ProjectPointOnSurf (Extrema algo), BRepFilletAPI_MakeFillet (ChFi3d_FilletShape), BRepOffsetAPI_MakeThickSolid.MakeThickSolidByJoin (mode and join type) and BRepExtrema_DistShapeShape (Extrema flags) all do. tools/sigdiff.py reports each of them, which is the point — they are the only four places the bound surface deliberately differs from upstream's.

None as an argument is rejected before the caster for simple overloads, so handle parameters that legitimately accept a null handle need an explicit nb::arg("x").none(). Null returns map to None unconditionally. The app passes no null handles today; inventory.py plus the app suite are the guard.

Packaging

One extension (OCP/_OCP.abi3.so) registering every OCP.* submodule via PyImport_AddModule, so import OCP.TopoDS works with no shim module per name and cls.__module__ reads OCP.TopoDS. The whole surface traffics in TopoDS_Shape, gp_* and handles, so sharing types in-process is free here and would otherwise lean on nanobind's cross-extension registry; registration order stays an explicit sequence in core.cpp; and cad_pool's forkserver warms one dlopen. One .cpp per module keeps incremental compiles cheap — only the link is shared.

abi3 (cp312-abi3) from the first build, matching forge/assay, so the Python 3.13 bump (roadmap 10D) needed no rebuild — the same wheel loads on the 3.13 the app now ships. The tag stays at cp312: it is a floor, and raising it would buy nothing. Escape hatch if a nanobind STABLE_ABI limitation ever bites: drop STABLE_ABI and wheel.py-api, since every deployed environment is on one interpreter. Stub generation works fine under abi3.

Version <occt>.N, asserted at configure time against the OCCT actually found, so occt_version() keeps reporting the truth for assay's goldens. Iteration builds carry .devN; the registry never allows republishing.

No sdist is ever published — it could not build without the builder image, and offering one invites the 4-core production host to try. That is enforced by only ever building wheels. Note sdist.exclude is not the way to do it: scikit-build-core feeds it into the wheel's package-file mapping too, so excluding * silently ships a wheel containing the compiled extension and none of the Python package. Relatedly, file selection is git-based, so the generated .pyi stubs are gitignored and re-included through sdist.include.

Fork safety: import starts no threads and creates no fork-hostile state, so cad_pool's forkserver keeps costing ~30 ms per job instead of a ~1.3 s spawn. Pinned by tests/test_forksafety.py.

The n3xd_ocp module

OCP.* stays a symbol-for-symbol drop-in so parity testing means something; anything additive lives in n3xd_ocp, shipped in the same wheel and backed by the same OCCT build. Only the leaf submodules are registered from C++ — creating the parent would put a bare module in sys.modules and a later import n3xd_ocp would skip the package's __init__.py.

Shipped: bintools (shape ↔ bytes, GIL-free, byte-identical to OCP.BinTools and asserted so) and _debug (test-only ownership introspection).

Designed, landing with the increment that binds their types:

# with Inc 1 (BRepGProp/GProp) — attacks the measured 94 % of
# face_candidate_anchors (0.99 s of 1.05 s for 690 faces) that is
# BRepGProp.SurfaceProperties_s called once per face from Python
def face_surface_props(shape, *, parallel=True) -> tuple[ndarray, ndarray]
    # areas[F], centroids[F,3], ordered by TopExp.MapShapes_s(FACE) index

# with Inc 2 (BRepMesh/Poly) — replaces the per-node and per-triangle Python
# loops in cad/tessellation.py
class FaceMesh:  nodes, triangles, normals, uv, face_index
def extract_meshes(shape, *, want_normals=True, want_uv=False,
                   apply_location=True, flip_reversed=True) -> list[FaceMesh]

# with Inc 4 — opt-in experiment: OSD::SetSignal turns some native faults into
# catchable Standard_Failure subclasses inside cad_pool children.  Never called
# at import; subprocess isolation stays regardless.
def set_signal(arm_fpe: bool = False) -> None

The backend adopts these after cutover, one call site at a time.

Matching upstream, and how that is checked

inventory.py --check answers "does the symbol exist". It cannot answer "does it mean the same thing", and the gap between those two is where a binding does real damage. nb::init<TopoDS_Shape, gp_Vec, bool, bool, bool> for BRepPrimAPI_MakePrism compiled cleanly and bound OCCT's semi-infinite overload, because that one takes a gp_Dir and gp_Dir converts implicitly from gp_Vec — so the flags shifted one position along and the result was a valid solid of the wrong shape. The fixture digests caught it; tools/sigdiff.py (make sigdiff) finds the class of bug directly, by diffing every bound constructor and member against the stock wheel.

Two further limits are worth stating, because they shaped how the increments were gated:

  • Static analysis cannot see instance methods. A method called on an object (vec.Reverse()) appears in no import, so --check is blind to it and --methods only guesses. Six such gaps survived to the end of Inc 4 and the app's suite found all six in one run — one of them, gp_Vec.Reverse, failing 311 tests by itself. The suite is the only real net here.
  • The app's suite cannot gate a single increment. backend/tests/ conftest.py imports n3xd.main, so every test fails at collection until the last module is bound. Increments are gated instead on reference values tools/gen_fixtures.py records from the stock wheel — measurements, per-face area and centroid in map order, mesh counts, and the Modified/Generated/ IsDeleted maps compared exactly, since that is the substrate the app's topological naming is built on.

OCCT 8.0 watchlist

Recorded as each module landed, so the 10E bump is a known quantity:

  • Standard_Failure derives std::exception, and the Raise/Throw/ Instance static helpers are gone. None of them is bound, so the exception table should port unchanged.
  • GeomLProp is superseded by the new GeomProp/BRepProp packages, whose results come back as structs with IsDefined flags instead of throwing. Only GeomLProp_SLProps.Normal is bound, so this is a one-line move.
  • StdPrs_BRepFont and StdPrs_BRepTextBuilder become deprecated typedefs of Font_BRepFont/Font_BRepTextBuilder. They are real classes in 7.9.3 and are bound as such; 8.0 makes it a rename behind the same OCP.StdPrs names.
  • Methods that returned handles through an out-parameter return them by value. BRep_Tool.Surface/Curve and the adaptors are the ones this binding touches.
  • The global math wrappers are deprecated in favour of std::. Nothing here binds them.
  • The byte-identity fixtures retire at the bump: a different kernel legitimately writes different BREP bytes. The measurement and history blocks stay.

Open questions

  • S5 — whether STEP needs CSF_* resource files shipped in the wheel. Settled at Inc 3: it does not. tests/test_inc3_io.py asserts that no CSF_* variable is set and then round-trips STEP and IGES, reading the declared units back off both — which is exactly the resource-less container the question was about. Both controllers initialise and the readers resolve millimetres. The wheel ships no share/ tree.
  • Byte-identity beyond 7.9.3 — the gate compares against the stock wheel, so it necessarily retires at the OCCT 8.0 bump (roadmap 10E), where the fixtures are re-blessed deliberately alongside assay's goldens. See the 8.0 watchlist above for what else moves.