The wheel is cp312-abi3, so it loads on 3.13 unchanged — the pin that has to move is the interpreter the parity venv is built with, since its whole point is resembling what the image ships. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDYRXGB8tW4NE7b91g7Kdb
272 lines
15 KiB
Markdown
272 lines
15 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 watchlist
|
|
|
|
Recorded as each module landed, so the 10E bump is a known quantity:
|
|
|
|
- `Standard_Failure` derives `std::exception`, and the `Raise`/`Throw`/
|
|
`Instance` static helpers are gone. None of them is bound, so the exception
|
|
table should port unchanged.
|
|
- `GeomLProp` is superseded by the new `GeomProp`/`BRepProp` packages, whose
|
|
results come back as structs with `IsDefined` flags instead of throwing. Only
|
|
`GeomLProp_SLProps.Normal` is bound, so this is a one-line move.
|
|
- `StdPrs_BRepFont` and `StdPrs_BRepTextBuilder` become deprecated typedefs of
|
|
`Font_BRepFont`/`Font_BRepTextBuilder`. They are real classes in 7.9.3 and are
|
|
bound as such; 8.0 makes it a rename behind the same `OCP.StdPrs` names.
|
|
- Methods that returned handles through an out-parameter return them by value.
|
|
`BRep_Tool.Surface`/`Curve` and the adaptors are the ones this binding
|
|
touches.
|
|
- The global math wrappers are deprecated in favour of `std::`. Nothing here
|
|
binds them.
|
|
- The byte-identity fixtures retire at the bump: a different kernel legitimately
|
|
writes different BREP bytes. The measurement and history blocks stay.
|
|
|
|
## 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.
|