From 8175b8aff3e1c4bada94e9495b00bf71aa47fbfe Mon Sep 17 00:00:00 2001 From: stroblme Date: Tue, 11 Aug 2026 15:07:47 +0200 Subject: [PATCH] docs Signed-off-by: stroblme --- .gitignore | 3 + README.md | 117 ++++++------- docs/adding-symbols.md | 43 ++--- docs/building.md | 109 ++++++------ docs/design.md | 369 +++++++++++++++-------------------------- 5 files changed, 261 insertions(+), 380 deletions(-) diff --git a/.gitignore b/.gitignore index 2a11702..227e3d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +NOTEPAD.md +CLAUDE.md + # Credentials (the Gitea publish PAT lives here) .secrets diff --git a/README.md b/README.md index 2c271ea..7358cae 100644 --- a/README.md +++ b/README.md @@ -1,76 +1,67 @@ # n3xd-ocp -Hand-written [nanobind](https://github.com/wjakob/nanobind) bindings for the -OpenCASCADE (OCCT) geometry kernel, covering exactly the surface the N3XD CAD -backend uses — 138 symbols across 47 `OCP.*` modules, not all of OCCT. - -The package 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. - -Status: **in production use** — all 138 symbols the app imports, across 53 -bound modules, published as `8.0.1.1` (OCCT 8.0.1). The app cut over on -2026-08-10 at `7.9.3.1` and took the kernel bump on 2026-08-11; its full -backend suite passes against both (1798 passed / 1 skipped, the same as the -stock wheel it replaced), a sweep of the whole project store reproduces every -part's geometry exactly (4486 parts; no change in statuses, volume, area, -bbox, entity counts, triangles or anchor digests), and BREP serialisation -stays byte-identical, which the pools and the content-addressed derive -payloads depend on — across the kernel bump as well, so nothing stored had to -be rewritten. +Hand-written [nanobind](https://github.com/wjakob/nanobind) wrapper for the OpenCASCADE (OCCT) geometry kernel. +In comparison to [`cadquery-ocp](https://github.com/cadquery/OCP), we get superlinear 7.0x speedup on the use cases in `n3xd`. Start with [docs/design.md](docs/design.md) for the decisions, [docs/building.md](docs/building.md) to build one, and -[docs/adding-symbols.md](docs/adding-symbols.md) to extend the surface. The -phase plan lives in the app repo at `docs-private/reference/roadmap.md` -(Phase 10). +[docs/adding-symbols.md](docs/adding-symbols.md) to extend the surface. -## Why +## Installation -`cadquery-ocp` lags OCCT (it still wraps 7.9.3; we are on 8.0.1), builds -Windows and macOS wheels we never use, and until recently forced a 638 MB VTK -dependency into the image. So this exists for version velocity, footprint, and -two defects that a binding we control prevents by construction: - -- OCCT sub-shapes are returned **by value**, so a wrapper can never alias a - `TShape` whose owner has died (this segfaulted a process-global face memo). -- Executing constructors (the two-argument `BRepAlgoAPI_*` forms) are **not - bound**, so the double-execution footgun is unrepresentable. - -It also releases the GIL around kernel calls and ships type stubs, neither of -which upstream does. - -**It is also considerably faster, which was not the point and turned out to -matter most.** With the app otherwise unchanged, its benchmark suite runs -194 s → 73 s, and rebuild time improves *superlinearly* with model complexity: -3.3x for a 4-feature part, 7.0x for a 32-feature one (13.4 s → 1.9 s). The -premise going in was that call overhead is irrelevant because the hotspots live -inside the kernel — true of any single call, false of the aggregate, because -this backend reaches OCCT once per face, per node and per edge. -`tools/bench_ext.py` has the numbers and the two places they contradicted the -plan. - -## Build - -OCCT is compiled once into a builder image and reused; it is never built on the -production host (4 cores, and a kernel build is multi-hour). Wheels are built -here on a dev box and published to the Gitea package registry. - -```bash -make image # once, ~40 min: compiles OCCT 8.0.1 into the builder image -make dev # inner loop: incremental compile + tests -make wheel # compile, stubs, auditwheel, self-containment smoke test -make publish # -> https://git.stroblme.de/api/packages/N3XD/pypi -``` - -Credentials go in `.secrets` (gitignored) as `UV_PUBLISH_USERNAME` / -`UV_PUBLISH_PASSWORD`. Consumers read anonymously — the package is public: +As packages sit on our Gitea instance for, you must install by providing the specific url, like: ```bash uv pip install --index-url https://git.stroblme.de/api/packages/N3XD/pypi/simple/ \ --prerelease=allow n3xd-ocp ``` -Versions are `.N`, enforced at configure time against the OCCT -actually found, so the kernel a wheel wraps is readable from its version alone. -The registry refuses to republish a version; iteration builds therefore carry a -`.devN` suffix and are the only ones the registry's cleanup rule collects. +Versions are `.N`, enforced at configure time against the OCCT actually found. + +## Usage + +`OCP` mirrors [`cadquery-ocp`](https://github.com/cadquery/OCP) symbol-for-symbol, so code written against it runs unchanged: + +```python +from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox +from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut +from OCP.TopTools import TopTools_ListOfShape + +box = BRepPrimAPI_MakeBox(10.0, 20.0, 30.0).Shape() +hole = BRepPrimAPI_MakeBox(3.0, 3.0, 30.0).Shape() + +args, tools = TopTools_ListOfShape(), TopTools_ListOfShape() +args.Append(box) +tools.Append(hole) + +cut = BRepAlgoAPI_Cut() +cut.SetArguments(args) +cut.SetTools(tools) +cut.Build() +result = cut.Shape() +``` + +One deliberate gap from upstream: constructors that run the algorithm immediately (the two-argument `BRepAlgoAPI_Cut(a, b)` form) aren't bound, only the deferred `SetArguments`/`SetTools`/`Build()` sequence above. See [docs/design.md](docs/design.md) for why. + +`n3xd_ocp` adds a handful of batch operations OCP doesn't have. They run on the same OCCT build and take/return plain `OCP` shapes: + +```python +import n3xd_ocp + +areas, centroids = n3xd_ocp.measure.face_surface_props(result) # one call for every face +meshes = n3xd_ocp.tess.extract_meshes(result) # triangulated faces, ready to render +data = n3xd_ocp.bintools.write_bytes(result) # BREP bytes, no temp file needed +``` + +## Build + +Wheels are built on a dev box and published to the Gitea package registry [here](https://git.stroblme.de/api/packages/N3XD/pypi). + +If you want to make modifications or build it yourself, here are some shortcuts: + +```bash +make image # compiles OCCT 8.0.1 into the builder image +make dev # incremental compile + tests +make wheel # compile, stubs, auditwheel, self-containment smoke test +make publish # publish to Gitea using .secret credentials +``` \ No newline at end of file diff --git a/docs/adding-symbols.md b/docs/adding-symbols.md index 6f61597..15bd9a8 100644 --- a/docs/adding-symbols.md +++ b/docs/adding-symbols.md @@ -1,7 +1,8 @@ # Adding symbols -The routine task: the app needs an OCCT class this binding does not expose yet. -Read [design.md](design.md) first if you are touching the machinery instead. +The routine task: the application consuming this binding needs an OCCT class +that isn't exposed yet. Read [design.md](design.md) first if you're touching +the machinery instead. ## 1. Find what is missing @@ -11,9 +12,9 @@ python tools/inventory.py --check # what the wheel lacks, b 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. +`--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 @@ -65,12 +66,12 @@ only in that a base class must precede its derived classes. `BRepClass3d_SolidClassifier`, `BRepExtrema_DistShapeShape`, `GCPnts_*`, `BRepBuilderAPI_Transform` — bind exactly as stock does. - **Enum** → `nb::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. +- **`Message_ProgressRange` parameters** → omit them unless a caller actually + needs one; `inventory.py --check` will tell you if that changes. Leaving + them out keeps signatures small. - **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. + writes through `L`, 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: @@ -109,18 +110,18 @@ 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_*.py` reproduces the same constructions under our wheel. -Counts and history maps must match exactly; floats compare at rel 1e-9. +**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. -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. +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 diff --git a/docs/building.md b/docs/building.md index 1975b80..a9bf14a 100644 --- a/docs/building.md +++ b/docs/building.md @@ -1,8 +1,8 @@ # Building -Everything runs through `make`; `make help` lists the targets. All compilation -happens inside the OCCT builder image, so the only host requirements are Docker -and (for publishing) `uv`. +Everything runs through `make` — `make help` lists the targets. All +compilation happens inside the OCCT builder image, so the only things you +need on the host are Docker and, for publishing, `uv`. ## The builder image @@ -11,102 +11,95 @@ and (for publishing) `uv`. tarball's sha256 are pinned) and installs it to `/opt/occt`. ```bash -make image # ~40 min on 16 cores -make image-push # needs: docker login git.stroblme.de +make image +make image-push ``` -It is a **compiler appliance**: wheel builds mount the repo into it rather than -`FROM` it, so iterating on the binding never re-layers the kernel. Add new -packages at the *end* of the Dockerfile — earlier layers stay cached and the -kernel is not recompiled. +It's a **compiler appliance**: wheel builds mount the repo into it rather +than building `FROM` it, so iterating on the binding never re-layers the +kernel. Add new packages at the *end* of the Dockerfile — earlier layers +stay cached and OCCT doesn't recompile. -The configuration turns Draw, VTK, Tk, Xlib, OpenGL and GLES off and FreeType -on, and the last layer asserts the result: TKService and TKV3d exist (text -emboss reaches `Font_BRepFont` through them), neither carries a `libGL`/`libX11` -`DT_NEEDED`, and freetype is linked. That is what lets the app image eventually -drop `libgl1` and `libx11-6`. Flags are `-O2`, no `-ffast-math`, no -`-march=native`: OCCT's version is a determinism input for assay's goldens, so -the binding must not introduce a different FP contract than the kernel it wraps. - -**Production never compiles OCCT.** The host has 4 cores and `make update` is a -`git pull` plus a compose build; the kernel arrives prebuilt inside the wheel, -which is the whole reason this image exists. +The configuration turns Draw, VTK, Tk, Xlib, OpenGL and GLES off and +FreeType on, and the last build layer checks the result: TKService and TKV3d +exist (needed for text emboss via `Font_BRepFont`), neither links +`libGL`/`libX11`, and FreeType is linked. Flags are `-O2`, no `-ffast-math`, +no `-march=native` — the build must produce the same floating-point results +as any other build of the same kernel version, since downstream code +compares geometry output across builds. ## Build cache -Object files, ccache, the pip cache and the build venv live under `$(CACHE)`, -default `/mnt/cache/n3xd/ocp` — off the root filesystem, which is tight on the -dev box. Change it per invocation with `make CACHE=/somewhere wheel`, or reset -it with `make clean-cache`. +Object files, ccache, the pip cache and the build venv live under +`$(CACHE)`, default `/mnt/cache/n3xd/ocp` — kept off the root filesystem, +which is tight on the dev box. Override it per invocation with +`make CACHE=/somewhere wheel`, or reset it with `make clean-cache`. ## The loop ```bash -make dev # incremental compile + pytest — the inner loop, seconds +make dev # incremental compile + pytest — the inner loop, seconds make test-asan # handle-model memory-safety check -make wheel # full build: compile, stubs, repack, auditwheel, smoke test +make wheel # full build: compile, stubs, repack, auditwheel, smoke test ``` -`make wheel` compiles twice on purpose: stubs are produced by importing the -freshly built extension, so they cannot exist before the first compile, and the -wheel is packed from the source tree. The second pass is incremental. +`make wheel` compiles twice on purpose: stubs come from importing the +freshly built extension, so they can't exist before the first compile, and +the wheel is packed from the source tree afterwards. The second pass is +incremental. -The smoke test installs the repaired wheel into a bare venv and imports it with -`LD_LIBRARY_PATH` unset — the only honest proof that `auditwheel` made it +The smoke test installs the repaired wheel into a bare venv and imports it +with `LD_LIBRARY_PATH` unset — the only real proof that `auditwheel` made it self-contained. ## Fixtures -`tests/data/*.brep` are the byte-identity references and are generated under the -**stock** wheel, from the app checkout: +`tests/data/*.brep` are byte-identity references, generated under the +**stock** wheel from a checkout of the app that consumes this binding: ```bash cd ../app && .venv/bin/python ../ocp/tools/gen_fixtures.py ``` -The generator asserts stock is idempotent for each fixture before recording its -digest — otherwise the gate would compare against a moving target. Regenerate -only when deliberately re-blessing. - -The OCCT 8.0.1 bump was expected to be such an occasion and turned out not to -be: every fixture round-tripped to the same digest under the new kernel, as did -the measurement, history and mesh-count blocks, so `tests/data/` was left -untouched. Do not assume the next bump re-blesses either — run the gate first -and let it say. Note also that the generator needs a wheel carrying more of -OCCT than this binding exposes (it is written against the stock wheel), so -regenerating is not currently possible from the app's own venv. +The generator asserts each fixture is idempotent under stock before +recording its digest, so the gate compares against a fixed target rather +than a moving one. Regenerate only when deliberately re-blessing — a kernel +bump doesn't automatically mean the fixtures need it, so run the gate first +and let it tell you before touching `tests/data/`. The generator also needs +a wheel carrying more of OCCT than this binding exposes, so it can't +currently run from this project's own venv. ## Publishing ```bash -make version # confirm what you are about to publish +make version # confirm what you're about to publish make publish # uv publish -> https://git.stroblme.de/api/packages/N3XD/pypi ``` Credentials come from `.secrets` (gitignored) as `UV_PUBLISH_USERNAME` / -`UV_PUBLISH_PASSWORD`; the username is a real Gitea username, not the PyPI -`__token__` convention, and the token needs `package: Read and Write`. +`UV_PUBLISH_PASSWORD` — a real Gitea username rather than PyPI's `__token__` +convention, with a token scoped to `package: Read and Write`. -A version can never be republished. Iteration builds therefore carry a `.devN` -suffix and are the only ones the registry's cleanup rule collects; bump `N` in +A version can never be republished. Iteration builds carry a `.devN` suffix +and are the only ones the registry's cleanup rule collects — bump `N` in `pyproject.toml` for each upload. -Consumers read anonymously — the N3XD org is public: +Consumers install anonymously, since the registry is public: ```bash uv pip install --index-url https://git.stroblme.de/api/packages/N3XD/pypi/simple/ \ --prerelease=allow n3xd-ocp ``` -Gitea serves no root `/simple/` listing (404), only the per-package path, which -is all pip and uv ask for. +Gitea serves no root `/simple/` listing (404), only the per-package path, +which is all pip and uv ever ask for. ## Parity -`tools/parity_venv.sh` builds a side environment where the app runs against this -wheel instead of the stock one. The app's manifests are never edited: both -distributions own the `OCP/` import path and a process can hold only one OCCT -build, so a swap is per-environment and reversible by re-syncing. +`tools/parity_venv.sh` builds a side environment where the app runs against +this wheel instead of the stock one, without touching the app's own +manifests — both distributions own the `OCP/` import path, so swapping is +per-environment and reversible by re-syncing. ```bash tools/parity_venv.sh # from the registry @@ -114,5 +107,5 @@ tools/parity_venv.sh --local # from wheelhouse/ python tools/inventory.py --check ``` -Coverage is expected to be partial until the increments land — `--check` prints -what is still missing, grouped by module, which is the work queue. +Coverage is expected to be partial until everything is bound — `--check` +prints what's still missing, grouped by module. diff --git a/docs/design.md b/docs/design.md index 86085f2..5ca5735 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1,293 +1,186 @@ # 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. +Notes on how this binding is built and why, for anyone touching the +machinery. Adding a new class instead? [adding-symbols.md](adding-symbols.md) +is the practical guide. ## 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. +A hand-written [nanobind](https://github.com/wjakob/nanobind) binding +covering the subset of OCCT actually in use, not the whole kernel — run +`tools/inventory.py` for the current count. It installs as a top-level `OCP`, +a drop-in replacement for `cadquery-ocp-novtk`. -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). +Binding *call* overhead was never the bottleneck — the real work happens +inside the C++ kernel — so the payoff is version velocity, a smaller +footprint, correctness around object ownership, and room to add APIs +upstream doesn't offer (releasing the GIL, bulk array extraction). ## The handle model `opencascade::handle` 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. +- **C++ → Python**: the wrapper is a *non-owning* nanobind instance pointing + at the C++ object, plus one handle kept alive alongside it. Reusing an + existing wrapper when one is already around keeps identity stable, so an + object crossing the boundary repeatedly doesn't pile up references. +- **Python → C++**: a plain handle copy, dropped when the caster goes out of + scope. No Python reference is taken — OCCT's own atomic refcount owns the + object, not the Python instance — which is exactly what makes releasing the + GIL safe: OCCT can copy handles on its own 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()` (`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. +constructor placement-news the object into the Python instance's storage, +which OCCT would later try to `delete` itself. Use `ocp_new()` +(`occt_transient.h`) instead — it heap-allocates and returns a handle, which +is what a transient needs when something keeps it alive past the call that +created it. -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 caster enforces this rather than trusting callers: a transient whose +`GetRefCount()` is zero isn't 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. +`TypeError` beats heap corruption. -*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). +Covered by `tests/test_handles.py`, run both normally and under +`make test-asan`: wrapper identity across a round trip, refcount balance, +null-handle ↔ `None`, and memory growth across repeated create/destroy +cycles. ## Fidelity rules -Verified against the installed stock wheel, not assumed. +Checked 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. +re-extracted copies of the same face hash equal but compare unequal. That's +intentional upstream behaviour worth keeping as-is: code that dedupes shapes +by hash and double-checks with `IsSame` relies on `==` *not* being +trustworthy on its own. **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. +holding a handle to its TShape, so a copy is cheap and owns what it points +at — this rules out a class of lifetime bug where a cached wrapper outlives +the structure it was explored from. **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. +constructor plus `SetArguments`/`SetTools`/`Build` — the two-argument forms +run the algorithm immediately, which invites calling `Build()` a second time +and running the operation twice. -**`_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. +**`_s` on every static**, via `OCP_DEF_S`. The rule is blanket, not +clash-driven — no static ships without it. -**Enums** use `nb::is_arithmetic()` + `export_values()`, reproducing pybind11's -int comparison and module-scope members (`from OCP.TopAbs import TopAbs_FACE`). +**Enums** use `nb::is_arithmetic()` + `.export_values()`, matching +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. +**Exceptions**: `Standard_Failure` derives `RuntimeError`, so `except +RuntimeError` keeps working without ever naming an OCCT class. Around twenty +concrete exception types are also bound under `OCP.Standard` / `OCP.StdFail` +and dispatched on the OCCT dynamic type, for callers that want to match by +name. -**`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. +**`TopoDS` is a namespace in OCCT**, not a class. Upstream still presents it +as a class carrying the `_s` statics (`TopoDS.Face_s(...)`), so +`mod_TopoDS.cpp` binds an empty carrier struct under that name to match. ## GIL policy -Released around calls that stay inside the kernel and cannot re-enter Python: +Released around calls that stay inside the kernel and can't re-enter Python: `Build`/`Perform`, meshing, `BRepCheck_Analyzer`, `RWStl`, and every -`n3xd_ocp` bulk API. Applied from an explicit list, never blanket. +`n3xd_ocp` bulk API. Held everywhere else — this is an explicit allow-list, +not a blanket policy. -**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. +**STEP and IGES readers/writers are the exception.** They go through the +process-global `Interface_Static` settings table, and IGES reading is +documented as not thread-safe. OCCT 8.0 added a thread-safety contract for +XSTEP, but it only covers one reader/writer per thread using the *default* +parameter set — it says nothing about a process that mutates +`Interface_Static` (a common way to configure units before reading). Holding +the GIL here costs nothing if imports are already serialized on the caller's +side, and keeps behaviour closer to upstream, which doesn't release the GIL +for XSTEP either. -`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. +`BinTools` releases the GIL for the kernel half of (de)serialisation. +Rather than bridging a `streambuf` that calls back into Python per chunk, +`occt_stream.h` reads the file-like object fully first and hands the kernel a +plain C++ stream — one extra copy of the payload, which `n3xd_ocp.bintools` +avoids for callers that already have `bytes`. -**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. +**An unregistered type can't be a default argument.** nanobind converts +default values to Python objects at *binding* time, so giving an enum this +binding doesn't register as a default (e.g. `"Algo"_a = +Extrema_ExtAlgo_Grad`) fails the whole extension's import with a bare +`std::bad_cast` and no further detail. Where the default is never overridden +in practice, the fix is to leave the argument off and let OCCT apply its own +default — `tools/sigdiff.py` reports every place the bound surface +deliberately differs from upstream this way. -**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. +**`None` is rejected before the caster reaches simple overloads.** Handle +parameters that legitimately accept a null handle need an explicit +`nb::arg("x").none()`. Null *returns* map to `None` unconditionally. ## 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. +**One extension** (`OCP/_OCP.abi3.so`) registers every `OCP.*` submodule via +`PyImport_AddModule`, so `import OCP.TopoDS` works without a shim module per +name. Types are shared in-process for free this way, instead of leaning on +nanobind's cross-extension registry. One `.cpp` file per module keeps +incremental compiles cheap — only the final link step 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. +**abi3 (`cp312-abi3`)**, so bumping the Python version doesn't require a +rebuild — the tag stays at `cp312` as a floor, not a target. Stub generation +works fine under abi3. If a nanobind STABLE_ABI limitation ever gets in the +way, dropping `STABLE_ABI` and pinning to one interpreter is the escape +hatch. -**Version `.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. +**Version `.N`**, asserted at configure time against the OCCT +actually found, so the version string always reflects the kernel underneath +it. Iteration builds carry a `.devN` suffix; the registry never allows +republishing a version. -**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`. +**No sdist is ever published** — it can't build without the builder image, +and shipping one just invites someone to try. `sdist.exclude` is *not* the +way to enforce that: scikit-build-core also feeds it into the wheel's +package-file mapping, so excluding everything silently ships a wheel with +the compiled extension and none of the Python package. -**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`. +**Fork safety**: importing the extension starts no threads and creates no +fork-hostile state, so forking right after import is cheap and safe. 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`. +`OCP.*` stays a symbol-for-symbol drop-in so parity testing against the +stock wheel means something; anything additive lives in `n3xd_ocp` instead, +shipped in the same wheel and built against the same OCCT. Only the leaf +submodules are registered from C++ — creating the parent package from C++ +would put a bare module in `sys.modules`, and a later `import n3xd_ocp` +would then skip its `__init__.py`. -Shipped: `bintools` (shape ↔ `bytes`, GIL-free, byte-identical to -`OCP.BinTools` and asserted so) and `_debug` (test-only ownership -introspection). +Shipped today: `bintools` (shape ↔ `bytes`, byte-identical to +`OCP.BinTools`), `measure` (batched per-face area and centroid) and `tess` +(triangulated meshes and edge polylines) — see their `.pyi` stubs for full +signatures, or the [usage examples](../README.md#usage) for a quick start. -Designed, landing with the increment that binds their types: +## Matching upstream, and how that's checked -```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 +`inventory.py --check` answers "does the symbol exist", not "does it behave +the same" — and that gap is where a binding can do real damage silently. +`tools/sigdiff.py` (`make sigdiff`) closes it by diffing every bound +constructor and member against the stock wheel; see +[adding-symbols.md](adding-symbols.md) for the bug it exists to catch. -# 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] +Two things static analysis can't see, worth keeping in mind: -# 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` 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`) 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` 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>`) 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. +- **Instance methods.** A method called on an object (`vec.Reverse()`) + appears in no import, so `--check` is blind to it; `--methods` only + guesses, by tracing local variables back to their constructor. +- **Whether the surface actually behaves like upstream.** Confidence that it + does comes from reference values recorded off the stock wheel — + measurements, per-face area and centroid, mesh counts, and the + `Modified`/`Generated`/`IsDeleted` history maps — reproduced under this + build and compared exactly.