Files
ocp/docs/design.md
stroblme 153f2e4cca Bump to OCCT 8.0.1
Four mechanical binding edits; the bound Python surface is unchanged
(sigdiff compared the 7.9.3.1 dump against 8.0.1.1's in both directions
and found no class, member or constructor arity moved), so the app needs
no change.

- NCollection_Utf8String is gone; NCollection_String is the same UTF-8
  type. Python name kept, since the app imports it.
- Standard_Failure lost its Standard_Transient RTTI when it moved to
  deriving std::exception; ExceptionType() replaced DynamicType()->Name()
  and returns the same class names the exception dispatch keys on.
- FindKey gained a size_t overload beside the int one, so the plain
  member pointer is ambiguous; overload_cast picks the int form, which
  keeps the negative-index guard.
- StdPrs moved from TKService to TKV3d, so CMakeLists links both.

The byte-identity fixtures did NOT retire, contrary to the watchlist:
all six round-trip to the same digests under the new kernel, as do the
measurement, history and mesh-count blocks, so tests/data is untouched
and the app's content-addressed BREP payloads stay valid.

Gates: binding suite 83 passed, 138/138 symbols, sigdiff clean both
directions, ASAN clean, app suite 1798 passed / 1 skipped (unchanged),
sweep over 4486 parts with anchors_digest and every status unchanged,
-m perf 16% faster on the 32-feature chain.
2026-08-11 12:43:54 +02:00

294 lines
16 KiB
Markdown

# 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](adding-symbols.md) instead.
## What this is
A hand-written [nanobind](https://github.com/wjakob/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:
```python
# 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 bump — what actually moved
Done at 8.0.1 (2026-08-11). Four edits across ~4 000 lines of binding, and the
bound Python surface came out **identical** — `sigdiff` compared 7.9.3.1's dump
against 8.0.1.1's in both directions and found no class, member or constructor
arity changed, so the drop-in contract held without the app being touched.
What broke, all of it mechanical:
- **`NCollection_Utf8String` is gone.** `NCollection_String`
(`NCollection_UtfString<char>`) is the same UTF-8 type and is what the font
API takes. The Python name is unchanged, since the app imports it.
- **`Standard_Failure` lost its `Standard_Transient` RTTI** when it moved to
deriving `std::exception`, so `DynamicType()->Name()` no longer compiles. The
virtual `ExceptionType()` replaced it and returns the same class names, which
is what the exception dispatch keys on — and what `cad_pool` marshals home.
- **The `Size()` → `size_t` migration added index overloads.** `FindKey` on the
indexed maps now has both an `int` and a `size_t` form, so a plain member
pointer is ambiguous; `nb::overload_cast<Standard_Integer>` picks the `int`
one, which keeps the negative-index guard.
- **`StdPrs` moved from TKService to TKV3d**, so `CMakeLists.txt` links both.
Three watchlist predictions were wrong, recorded here because the reasoning
behind them was plausible:
- `StdPrs_BRepFont`/`StdPrs_BRepTextBuilder` did **not** become typedefs of
`Font_BRepFont`/`Font_BRepTextBuilder`. The aliasing runs the other way — they
are still the real classes and the `Font_*` names are the typedefs.
- `GeomLProp` was **not** superseded by `GeomProp`/`BRepProp`. It became a
template (`GeomLProp_SLProps` is now an alias for
`GeomLProp_SLPropsBase<handle<Geom_Surface>>`) and kept the constructor and
accessors this binding uses, so the module compiled unchanged.
- **The byte-identity fixtures did not retire.** A different kernel was assumed
to write different BREP bytes; 8.0.1 does not. All six fixtures round-trip to
the same digests, and the measurement, history and mesh-count blocks match
too, so `tests/data/` was left alone and no re-bless happened. That also means
the app's content-addressed BREP payloads stay valid across the bump.
Unchanged as predicted: the handle model (`occt_handle.h` needed nothing, ASAN
clean), the exception *table* (the removed `Raise`/`Throw`/`Instance` helpers
were never bound), the deprecated out-parameter handle returns this binding
still uses, and the global math wrappers it never bound.
## 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.