Files
ocp/docs/adding-symbols.md
stroblme 2c2c4e4712 10C Inc 1: core modeling
Binds the modeling core the app builds every feature out of — 93 of its 138
symbols now resolve, up from 34. New modules: GeomAbs, Geom2d, Geom,
TCollection, TColgp, TColStd, GProp, BRepGProp, Bnd, BRepBndLib, Adaptor3d,
BRepAdaptor, GeomLProp, GeomAPI, BRepBuilderAPI, BOPAlgo, BRepAlgoAPI,
BRepPrimAPI, GC, BRepMesh; gp and BRep_Tool completed.

Three structural decisions:

- BRepBuilderAPI_MakeShape carries Build/Shape/Generated/Modified/IsDeleted for
  every maker in the binding, so the booleans, the primitives and (later) the
  fillet builders all answer the app's duck-typed provenance layer through
  ordinary virtual dispatch. History lists come back copied, so they outlive
  the builder.
- The executing two-argument BRepAlgoAPI constructors stay unbound; operands go
  in through SetArguments/SetTools. Section keeps Init1/Init2, which are plain
  setters. BOPAlgo moved up from Inc 2 — SetGlue needs its enum.
- Adaptor3d is registered although the app never imports it: every method it
  calls on BRepAdaptor_Curve/Surface is a virtual declared there, so binding
  them once on the bases leaves mod_BRepAdaptor.cpp with just constructors.

Gate: tests/test_inc1_modeling.py against reference values gen_fixtures.py now
records from the stock wheel — measurements, per-face area/centroid in map
order, mesh counts, and the boolean history map compared exactly, since that is
the substrate the app's topological naming is built on.

tools/sigdiff.py compares our bound surface against stock's, because a wrong
nb::init<> is silent: MakePrism's five-argument form bound OCCT's semi-infinite
gp_Dir overload (gp_Dir converts from gp_Vec), producing a valid solid of the
wrong shape with the flags shifted along. The fixture digest caught it; sigdiff
finds the class of bug directly, and now reports only one deliberate deviation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfriM8XUkn7uYf5Dwe2xo6
2026-08-10 19:52:58 +02:00

6.1 KiB

Adding symbols

The routine task: the app needs an OCCT class this binding does not expose yet. Read design.md first if you are 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, 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_<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 APIOCP_RETURN_COPY. Explorers, iterators, map lookups, Generated/Modified lists — anything handing out a reference into storage the caller does not own.
  • Static methodOCP_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<T, Args...>(). Never nb::init<> — see design.md.
  • Long kernel callOCP_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.
  • Enumnb::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:

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

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<N>_*.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.<name>") and re-export them in python/n3xd_ocp/__init__.py.