6.1 KiB
Adding symbols
The routine task: the application consuming this binding needs an OCCT class that isn't exposed yet. Read design.md first if you're touching the machinery instead.
1. Find what is missing
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, a natural way to scope a batch of work. 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_<Name>.cpp.
#include "../common/occt_module.h" // brings in the handle caster and "_a"
#include "../common/occt_policies.h" // OCP_RETURN_COPY, OCP_NOGIL
#include <Some_Class.hxx>
void register_Some(nb::module_ &root) {
nb::module_ m = ocp_submodule(root, "Some");
nb::class_<Some_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/Modifiedlists — 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_Transientin thenb::class_declaration and bind constructors withocp_new<T, Args...>(). Nevernb::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/BuildAPI exists and the constructor duplicatesBuild(): theBRepAlgoAPI_*booleans and splitter. Bind their default constructor plus theSetX/Buildsequence. 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_ProgressRangeparameters → omit them unless a caller actually needs one;inventory.py --checkwill tell you if that changes. Leaving them out keeps signatures small.- Out-parameters stay out-parameters.
BRep_Tool.Triangulation_s(F, L)writes throughL, matching upstream — returning a tuple would look tidier and would break the fidelity this binding exists to keep.
When in doubt about a signature, ask the stock wheel rather than guessing:
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<TopoDS_Shape, gp_Vec, bool, bool, bool>forBRepPrimAPI_MakePrismcompiled fine and bound the wrong constructor. OCCT's finite-prism overload takes four arguments; the five-argument one takes agp_Dirfor a semi-infinite prism, andgp_Dirconverts implicitly fromgp_Vec. The result was a valid solid of the wrong shape, with theCopyandCanonizeflags 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
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 consuming app's own tests can't gate a single addition. It imports
the whole application, and therefore the whole OCP surface, so every one of
its tests fails at collection until the last module you're adding is bound.
Fixtures close that gap 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
like tests/test_inc1_modeling.py reproduce the same constructions under
this wheel. Counts and history maps must match exactly; floats compare at
rel 1e-9.
Once everything the app needs is bound, the real gate is running its full test suite against this wheel through the parity venv.
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.<name>") and re-export them in
python/n3xd_ocp/__init__.py.