# Adding symbols The routine task: the app needs an OCCT class this binding does not expose yet. Read [design.md](design.md) first if you are touching the machinery instead. ## 1. Find what is missing ```bash python tools/inventory.py --emit # re-scan the app for OCP usage python tools/inventory.py --check # what the wheel lacks, by module python tools/inventory.py --methods --only BRepAdaptor # what to bind on each class ``` `--check` groups gaps by module, which is how increments are scoped. It only sees symbols reached through an import (`TopExp.MapShapes_s`), so it answers *which* classes to bind but not *what* to bind on them. `--methods` answers the second question: it resolves variables assigned straight from a constructor and reports the methods called on them, plus chained calls as `Klass.Outer() -> Inner` (those constrain `Outer`'s **return** type — `adaptor.Cylinder().Radius()` is a requirement on `gp_Cylinder`). It is a heuristic — it does not follow arguments, returns or attributes, and a local reassigned from something else shows up as noise — so read it as a starting surface, not a specification. ## 2. Write the module One file per OCP module: `src/modules/mod_.cpp`. ```cpp #include "../common/occt_module.h" // brings in the handle caster and "_a" #include "../common/occt_policies.h" // OCP_RETURN_COPY, OCP_NOGIL #include void register_Some(nb::module_ &root) { nb::module_ m = ocp_submodule(root, "Some"); nb::class_(m, "Some_Class") .def(nb::init<>()) .def("Value", &Some_Class::Value, "index"_a, OCP_RETURN_COPY) .def("Build", &Some_Class::Build, OCP_NOGIL); } ``` Declare and call `register_Some` in `src/core.cpp`. Registration order matters only in that a base class must precede its derived classes. ## 3. The checklist - **Shape-returning API** → `OCP_RETURN_COPY`. Explorers, iterators, map lookups, `Generated`/`Modified` lists — anything handing out a reference into storage the caller does not own. - **Static method** → `OCP_DEF_S(cls, "Name", ...)`, which appends `_s`. Every static, without exception. - **Transient (handle-managed) class** → derive from `Standard_Transient` in the `nb::class_` declaration and bind constructors with `ocp_new()`. Never `nb::init<>` — see design.md. - **Long kernel call** → `OCP_NOGIL`, but only if it cannot re-enter Python. - **Executing constructor** → banned only where a deferred `SetX`/`Build` API exists and the constructor duplicates `Build()`: the `BRepAlgoAPI_*` booleans and splitter. Bind their default constructor plus the `SetX`/`Build` sequence. Classes that only compute in their constructor and have no deferred form — `BRepMesh_IncrementalMesh`, `BRepCheck_Analyzer`, `GeomAPI_*`, `BRepClass3d_SolidClassifier`, `BRepExtrema_DistShapeShape`, `GCPnts_*`, `BRepBuilderAPI_Transform` — bind exactly as stock does. - **Enum** → `nb::is_arithmetic()` and `.export_values()`. - **`Message_ProgressRange` parameters** → omit them. The app never passes one (no `OCP.Message` import anywhere), and leaving them out keeps signatures small. Add the module if `--check` ever reports it. - **Out-parameters** stay out-parameters. `BRep_Tool.Triangulation_s(F, L)` writes through `L` because the app calls it that way; returning a tuple would be tidier and wrong. When in doubt about a signature, ask the stock wheel rather than guessing: ```bash cd ../app && uv run --project backend python -c \ "from OCP.BRep import BRep_Tool; print(BRep_Tool.Triangulation_s.__doc__)" ``` And after writing a module, run `make sigdiff`, which asks it about every class at once. This is not pedantry about matching upstream — it catches the one mistake in this codebase that is both easy to make and silent: > `nb::init` for `BRepPrimAPI_MakePrism` > compiled fine and bound the **wrong constructor**. OCCT's finite-prism > overload takes four arguments; the five-argument one takes a `gp_Dir` for a > semi-infinite prism, and `gp_Dir` converts implicitly from `gp_Vec`. The > result was a valid solid of the wrong shape, with the `Copy` and `Canonize` > flags shifted one position along. Anything `sigdiff` reports is either that bug or a deliberate deviation; if it is deliberate, say so in a comment where the class is bound. ## 4. New toolkits If the linker cannot find a symbol, the class lives in a toolkit not yet listed in `CMakeLists.txt` (`target_link_libraries(_OCP PRIVATE ...)`). Add it there; `auditwheel` bundles whatever the linker records, so nothing else changes. ## 5. Verify and ship ```bash make dev # compile + tests make test-asan # if you touched ownership or added transients make wheel # bump the .devN in pyproject.toml first make publish tools/parity_venv.sh && python tools/inventory.py --check ``` **The app's own tests cannot gate an individual increment.** `backend/tests/ conftest.py` imports `n3xd.main`, which pulls in the whole app and therefore the whole OCP surface, so every backend test fails at collection until the last module is bound. Increments are gated here instead: `tools/gen_fixtures.py` records reference values from the *stock* wheel (counts, `Modified`/`Generated`/ `IsDeleted` history maps, measured floats) into `tests/data/manifest.json`, and `tests/test_inc_*.py` reproduces the same constructions under our wheel. Counts and history maps must match exactly; floats compare at rel 1e-9. The app's full suite is the **Inc 4** gate, run in the parity venv, alongside `pytest -m perf` and `backend/tools/rebuild_sweep.py --diff` over the project store. ## Adding to `n3xd_ocp` instead Anything that is not a faithful mirror of an upstream symbol belongs in `src/ext/` under the `n3xd_ocp` namespace: bulk array APIs, batched measurement, anything GIL-free that upstream does not offer. `OCP.*` staying a symbol-for-symbol drop-in is what makes parity testing meaningful, so keep additive work out of it. Register leaf modules with `ocp_named_module("n3xd_ocp.")` and re-export them in `python/n3xd_ocp/__init__.py`.