Files
ocp/docs/adding-symbols.md
stroblme 6139852768 Phase 10A/10B Inc 0: build system, handle model, first module surface
Builds n3xd-ocp end to end and publishes 7.9.3.1.dev1 to the Gitea registry,
where it installs anonymously and passes its suite.

- occt/Dockerfile: OCCT 7.9.3 compiled once into a manylinux_2_28 builder
  image (base digest + tarball sha256 pinned), Draw/VTK/Tk/Xlib/OpenGL off,
  FreeType on, -O2 without fast-math or march=native. A final layer asserts
  TKService/TKV3d exist with no libGL/libX11 DT_NEEDED, which is what lets the
  app image drop libgl1/libx11-6. Mounted into, never built FROM.
- scikit-build-core + nanobind STABLE_ABI -> one cp312-abi3 extension that
  registers every OCP.* submodule via PyImport_AddModule, so `import
  OCP.TopoDS` needs no shim and cls.__module__ is right. Version <occt>.N is
  asserted against the OCCT found, keeping occt_version() truthful.
- occt_handle.h: type caster for opencascade::handle<T> over OCCT's intrusive
  refcount. Wrappers are non-owning instances holding exactly one handle in
  their keep-alive list, reusing an existing wrapper so identity survives a
  round trip. Transient constructors go through ocp_new (never nb::init<>,
  which would let OCCT delete nanobind's storage); the caster refuses a
  refcount-0 object rather than corrupt the heap. Verified under ASAN with no
  memory-safety errors, plus an RSS bound over 50k create/destroy cycles.
- Sub-shapes are returned by value everywhere, making the TShape lifetime class
  that segfaulted a process-global face memo unrepresentable.
- Standard_Failure derives RuntimeError, with ~20 concrete types dispatched on
  the dynamic OCCT type (cad_pool marshals failures home by type name).
- Inc 0 surface: gp subset, TopAbs, TopoDS (+ downcasts), TopExp, TopLoc,
  TopTools, BRep, BinTools, Poly, Standard. 34 of the app's 139 symbols.
- n3xd_ocp: additive APIs kept out of the OCP namespace so parity testing stays
  meaningful. bintools (shape <-> bytes, GIL-free, byte-identical) and _debug.

Two findings worth the record, both verified against the stock wheel rather
than assumed: upstream binds __hash__ but leaves __eq__ at identity, which is
exactly what geom_memo.py's hash-bucket + IsSame scan is built around, so we
match it instead of "fixing" it; and BinTools can release the GIL after all, by
slurping the file-like object instead of bridging a streambuf that would call
back into Python.

Gate: BREP round-trips are byte-identical to cadquery-ocp-novtk across six
fixtures (the generator asserts stock idempotency first). That matters beyond
IPC — derive.py content-addresses BREP payloads by sha256 and stores the ref.
2026-08-10 16:10:28 +02:00

4.0 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 installed wheel lacks, by module

--check groups gaps by module, which is how increments are scoped. Note it only sees symbols reached through an import (TopExp.MapShapes_s), not methods called on instances (shape.IsSame(...)) — a green --check is necessary, not sufficient. Running the app's own tests in the parity venv is what catches the rest, loudly, as an AttributeError.

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 → do not bind it. Bind the default constructor plus the SetX/Build sequence.
  • 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 && .venv/bin/python -c "from OCP.BRep import BRep_Tool; print(BRep_Tool.Triangulation_s.__doc__)"

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

Then run the slice of the app's suite the increment claims — Inc 1 is gated on backend/tests/test_geom_memo.py, Inc 2 on the tessellation tests plus pytest -m perf, Inc 4 on test_cad_pool.py and test_derive.py, and the cutover on the full suite plus 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.