Signed-off-by: stroblme <stroblme@posteo.de>
This commit is contained in:
2026-08-11 15:07:47 +02:00
parent ab32514df1
commit 8175b8aff3
5 changed files with 261 additions and 380 deletions

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
NOTEPAD.md
CLAUDE.md
# Credentials (the Gitea publish PAT lives here) # Credentials (the Gitea publish PAT lives here)
.secrets .secrets

117
README.md
View File

@@ -1,76 +1,67 @@
# n3xd-ocp # n3xd-ocp
Hand-written [nanobind](https://github.com/wjakob/nanobind) bindings for the Hand-written [nanobind](https://github.com/wjakob/nanobind) wrapper for the OpenCASCADE (OCCT) geometry kernel.
OpenCASCADE (OCCT) geometry kernel, covering exactly the surface the N3XD CAD In comparison to [`cadquery-ocp](https://github.com/cadquery/OCP), we get superlinear 7.0x speedup on the use cases in `n3xd`.
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.
Start with [docs/design.md](docs/design.md) for the decisions, Start with [docs/design.md](docs/design.md) for the decisions,
[docs/building.md](docs/building.md) to build one, and [docs/building.md](docs/building.md) to build one, and
[docs/adding-symbols.md](docs/adding-symbols.md) to extend the surface. The [docs/adding-symbols.md](docs/adding-symbols.md) to extend the surface.
phase plan lives in the app repo at `docs-private/reference/roadmap.md`
(Phase 10).
## Why ## Installation
`cadquery-ocp` lags OCCT (it still wraps 7.9.3; we are on 8.0.1), builds As packages sit on our Gitea instance for, you must install by providing the specific url, like:
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:
```bash ```bash
uv pip install --index-url https://git.stroblme.de/api/packages/N3XD/pypi/simple/ \ uv pip install --index-url https://git.stroblme.de/api/packages/N3XD/pypi/simple/ \
--prerelease=allow n3xd-ocp --prerelease=allow n3xd-ocp
``` ```
Versions are `<occt-version>.N`, enforced at configure time against the OCCT Versions are `<occt-version>.N`, enforced at configure time against the OCCT actually found.
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 ## Usage
`.devN` suffix and are the only ones the registry's cleanup rule collects.
`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
```

View File

@@ -1,7 +1,8 @@
# Adding symbols # Adding symbols
The routine task: the app needs an OCCT class this binding does not expose yet. The routine task: the application consuming this binding needs an OCCT class
Read [design.md](design.md) first if you are touching the machinery instead. that isn't exposed yet. Read [design.md](design.md) first if you're touching
the machinery instead.
## 1. Find what is missing ## 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 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 `--check` groups gaps by module, a natural way to scope a batch of work. It
sees symbols reached through an import (`TopExp.MapShapes_s`), so it answers only sees symbols reached through an import (`TopExp.MapShapes_s`), so it
*which* classes to bind but not *what* to bind on them. answers *which* classes to bind but not *what* to bind on them.
`--methods` answers the second question: it resolves variables assigned `--methods` answers the second question: it resolves variables assigned
straight from a constructor and reports the methods called on them, plus 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_*`, `BRepClass3d_SolidClassifier`, `BRepExtrema_DistShapeShape`, `GCPnts_*`,
`BRepBuilderAPI_Transform` — bind exactly as stock does. `BRepBuilderAPI_Transform` — bind exactly as stock does.
- **Enum** → `nb::is_arithmetic()` and `.export_values()`. - **Enum** → `nb::is_arithmetic()` and `.export_values()`.
- **`Message_ProgressRange` parameters** → omit them. The app never passes one - **`Message_ProgressRange` parameters** → omit them unless a caller actually
(no `OCP.Message` import anywhere), and leaving them out keeps signatures needs one; `inventory.py --check` will tell you if that changes. Leaving
small. Add the module if `--check` ever reports it. them out keeps signatures small.
- **Out-parameters** stay out-parameters. `BRep_Tool.Triangulation_s(F, L)` - **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 writes through `L`, matching upstream — returning a tuple would look tidier
be tidier and wrong. and would break the fidelity this binding exists to keep.
When in doubt about a signature, ask the stock wheel rather than guessing: 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 tools/parity_venv.sh && python tools/inventory.py --check
``` ```
**The app's own tests cannot gate an individual increment.** `backend/tests/ **The consuming app's own tests can't gate a single addition.** It imports
conftest.py` imports `n3xd.main`, which pulls in the whole app and therefore the the whole application, and therefore the whole `OCP` surface, so every one of
whole OCP surface, so every backend test fails at collection until the last its tests fails at collection until the last module you're adding is bound.
module is bound. Increments are gated here instead: `tools/gen_fixtures.py` Fixtures close that gap instead: `tools/gen_fixtures.py` records reference
records reference values from the *stock* wheel (counts, `Modified`/`Generated`/ values from the *stock* wheel (counts, `Modified`/`Generated`/`IsDeleted`
`IsDeleted` history maps, measured floats) into `tests/data/manifest.json`, and history maps, measured floats) into `tests/data/manifest.json`, and tests
`tests/test_inc<N>_*.py` reproduces the same constructions under our wheel. like `tests/test_inc1_modeling.py` reproduce the same constructions under
Counts and history maps must match exactly; floats compare at rel 1e-9. 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 Once everything the app needs is bound, the real gate is running its full
`pytest -m perf` and `backend/tools/rebuild_sweep.py --diff` over the project test suite against this wheel through the parity venv.
store.
## Adding to `n3xd_ocp` instead ## Adding to `n3xd_ocp` instead

View File

@@ -1,8 +1,8 @@
# Building # Building
Everything runs through `make`; `make help` lists the targets. All compilation Everything runs through `make` `make help` lists the targets. All
happens inside the OCCT builder image, so the only host requirements are Docker compilation happens inside the OCCT builder image, so the only things you
and (for publishing) `uv`. need on the host are Docker and, for publishing, `uv`.
## The builder image ## The builder image
@@ -11,102 +11,95 @@ and (for publishing) `uv`.
tarball's sha256 are pinned) and installs it to `/opt/occt`. tarball's sha256 are pinned) and installs it to `/opt/occt`.
```bash ```bash
make image # ~40 min on 16 cores make image
make image-push # needs: docker login git.stroblme.de make image-push
``` ```
It is a **compiler appliance**: wheel builds mount the repo into it rather than It's a **compiler appliance**: wheel builds mount the repo into it rather
`FROM` it, so iterating on the binding never re-layers the kernel. Add new than building `FROM` it, so iterating on the binding never re-layers the
packages at the *end* of the Dockerfile — earlier layers stay cached and the kernel. Add new packages at the *end* of the Dockerfile — earlier layers
kernel is not recompiled. stay cached and OCCT doesn't recompile.
The configuration turns Draw, VTK, Tk, Xlib, OpenGL and GLES off and FreeType The configuration turns Draw, VTK, Tk, Xlib, OpenGL and GLES off and
on, and the last layer asserts the result: TKService and TKV3d exist (text FreeType on, and the last build layer checks the result: TKService and TKV3d
emboss reaches `Font_BRepFont` through them), neither carries a `libGL`/`libX11` exist (needed for text emboss via `Font_BRepFont`), neither links
`DT_NEEDED`, and freetype is linked. That is what lets the app image eventually `libGL`/`libX11`, and FreeType is linked. Flags are `-O2`, no `-ffast-math`,
drop `libgl1` and `libx11-6`. Flags are `-O2`, no `-ffast-math`, no no `-march=native` — the build must produce the same floating-point results
`-march=native`: OCCT's version is a determinism input for assay's goldens, so as any other build of the same kernel version, since downstream code
the binding must not introduce a different FP contract than the kernel it wraps. compares geometry output across builds.
**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.
## Build cache ## Build cache
Object files, ccache, the pip cache and the build venv live under `$(CACHE)`, Object files, ccache, the pip cache and the build venv live under
default `/mnt/cache/n3xd/ocp` — off the root filesystem, which is tight on the `$(CACHE)`, default `/mnt/cache/n3xd/ocp` kept off the root filesystem,
dev box. Change it per invocation with `make CACHE=/somewhere wheel`, or reset which is tight on the dev box. Override it per invocation with
it with `make clean-cache`. `make CACHE=/somewhere wheel`, or reset it with `make clean-cache`.
## The loop ## The loop
```bash ```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 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 `make wheel` compiles twice on purpose: stubs come from importing the
freshly built extension, so they cannot exist before the first compile, and the freshly built extension, so they can't exist before the first compile, and
wheel is packed from the source tree. The second pass is incremental. 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 The smoke test installs the repaired wheel into a bare venv and imports it
`LD_LIBRARY_PATH` unset — the only honest proof that `auditwheel` made it with `LD_LIBRARY_PATH` unset — the only real proof that `auditwheel` made it
self-contained. self-contained.
## Fixtures ## Fixtures
`tests/data/*.brep` are the byte-identity references and are generated under the `tests/data/*.brep` are byte-identity references, generated under the
**stock** wheel, from the app checkout: **stock** wheel from a checkout of the app that consumes this binding:
```bash ```bash
cd ../app && .venv/bin/python ../ocp/tools/gen_fixtures.py cd ../app && .venv/bin/python ../ocp/tools/gen_fixtures.py
``` ```
The generator asserts stock is idempotent for each fixture before recording its The generator asserts each fixture is idempotent under stock before
digest — otherwise the gate would compare against a moving target. Regenerate recording its digest, so the gate compares against a fixed target rather
only when deliberately re-blessing. 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
The OCCT 8.0.1 bump was expected to be such an occasion and turned out not to and let it tell you before touching `tests/data/`. The generator also needs
be: every fixture round-tripped to the same digest under the new kernel, as did a wheel carrying more of OCCT than this binding exposes, so it can't
the measurement, history and mesh-count blocks, so `tests/data/` was left currently run from this project's own venv.
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.
## Publishing ## Publishing
```bash ```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 make publish # uv publish -> https://git.stroblme.de/api/packages/N3XD/pypi
``` ```
Credentials come from `.secrets` (gitignored) as `UV_PUBLISH_USERNAME` / Credentials come from `.secrets` (gitignored) as `UV_PUBLISH_USERNAME` /
`UV_PUBLISH_PASSWORD`; the username is a real Gitea username, not the PyPI `UV_PUBLISH_PASSWORD` a real Gitea username rather than PyPI's `__token__`
`__token__` convention, and the token needs `package: Read and Write`. convention, with a token scoped to `package: Read and Write`.
A version can never be republished. Iteration builds therefore carry a `.devN` A version can never be republished. Iteration builds carry a `.devN` suffix
suffix and are the only ones the registry's cleanup rule collects; bump `N` in and are the only ones the registry's cleanup rule collects bump `N` in
`pyproject.toml` for each upload. `pyproject.toml` for each upload.
Consumers read anonymously — the N3XD org is public: Consumers install anonymously, since the registry is public:
```bash ```bash
uv pip install --index-url https://git.stroblme.de/api/packages/N3XD/pypi/simple/ \ uv pip install --index-url https://git.stroblme.de/api/packages/N3XD/pypi/simple/ \
--prerelease=allow n3xd-ocp --prerelease=allow n3xd-ocp
``` ```
Gitea serves no root `/simple/` listing (404), only the per-package path, which Gitea serves no root `/simple/` listing (404), only the per-package path,
is all pip and uv ask for. which is all pip and uv ever ask for.
## Parity ## Parity
`tools/parity_venv.sh` builds a side environment where the app runs against this `tools/parity_venv.sh` builds a side environment where the app runs against
wheel instead of the stock one. The app's manifests are never edited: both this wheel instead of the stock one, without touching the app's own
distributions own the `OCP/` import path and a process can hold only one OCCT manifests — both distributions own the `OCP/` import path, so swapping is
build, so a swap is per-environment and reversible by re-syncing. per-environment and reversible by re-syncing.
```bash ```bash
tools/parity_venv.sh # from the registry tools/parity_venv.sh # from the registry
@@ -114,5 +107,5 @@ tools/parity_venv.sh --local # from wheelhouse/
python tools/inventory.py --check python tools/inventory.py --check
``` ```
Coverage is expected to be partial until the increments land — `--check` prints Coverage is expected to be partial until everything is bound — `--check`
what is still missing, grouped by module, which is the work queue. prints what's still missing, grouped by module.

View File

@@ -1,293 +1,186 @@
# Design record # Design record
Decisions that are expensive to revisit, and the evidence behind them. If you Notes on how this binding is built and why, for anyone touching the
are adding symbols rather than changing the machinery, read machinery. Adding a new class instead? [adding-symbols.md](adding-symbols.md)
[adding-symbols.md](adding-symbols.md) instead. is the practical guide.
## What this is ## What this is
A hand-written [nanobind](https://github.com/wjakob/nanobind) binding of the A hand-written [nanobind](https://github.com/wjakob/nanobind) binding
OCCT surface the N3XD backend actually uses — 139 symbols across 48 `OCP.*` covering the subset of OCCT actually in use, not the whole kernel — run
modules, per `tools/inventory.py`, not all of OCCT. It installs as a top-level `tools/inventory.py` for the current count. It installs as a top-level `OCP`,
`OCP`, so it is a drop-in replacement for `cadquery-ocp-novtk` and the app's 442 a drop-in replacement for `cadquery-ocp-novtk`.
import sites stay untouched.
Binding *call* overhead was never the bottleneck — the CAD hotspots are inside Binding *call* overhead was never the bottleneck — the real work happens
the C++ kernel — so the payoff is version velocity, footprint, correctness at inside the C++ kernel — so the payoff is version velocity, a smaller
the ownership boundary, and the freedom to add APIs upstream cannot (GIL footprint, correctness around object ownership, and room to add APIs
release, bulk array extraction). upstream doesn't offer (releasing the GIL, bulk array extraction).
## The handle model ## The handle model
`opencascade::handle<T>` is cast by `src/common/occt_handle.h`, modelled on `opencascade::handle<T>` is cast by `src/common/occt_handle.h`, modelled on
nanobind's own `stl/shared_ptr.h`. nanobind's own `stl/shared_ptr.h`.
- **C++ → Python**: the wrapper is a *non-owning* nanobind instance pointing at - **C++ → Python**: the wrapper is a *non-owning* nanobind instance pointing
the C++ object, plus one handle stored in its keep-alive list. An existing at the C++ object, plus one handle kept alive alongside it. Reusing an
wrapper is reused (`is_new == false`), so identity holds while a wrapper is existing wrapper when one is already around keeps identity stable, so an
alive, and a long-lived object crossing the boundary repeatedly does not pile object crossing the boundary repeatedly doesn't pile up references.
up redundant references. Transients are polymorphic, so `nb_type_put_p` - **Python → C++**: a plain handle copy, dropped when the caster goes out of
downcasts on the dynamic type. scope. No Python reference is taken — OCCT's own atomic refcount owns the
- **Python → C++**: a plain handle copy, balanced when the caster dies after the object, not the Python instance — which is exactly what makes releasing the
call. Unlike `shared_ptr`, no Python reference is taken: OCCT's intrusive GIL safe: OCCT can copy handles on its own threads without touching the
atomic refcount owns the object's memory, not the Python instance. That is interpreter.
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 **Transient constructors never use `nb::init<>`.** nanobind's normal
constructor placement-news the object into the Python instance's storage, which constructor placement-news the object into the Python instance's storage,
OCCT would later `delete`. Use `ocp_new<T, Args...>()` (`occt_transient.h`), which OCCT would later try to `delete` itself. Use `ocp_new<T, Args...>()`
which heap-allocates and returns a handle. This is not hypothetical: the app (`occt_transient.h`) instead — it heap-allocates and returns a handle, which
builds a `Geom_BSplineCurve` in `sketch_builder/edges.py` and hands it to is what a transient needs when something keeps it alive past the call that
`BRepBuilderAPI_MakeEdge`, which keeps a handle past the call. created it.
The caster enforces the rule rather than trusting it: a transient whose The caster enforces this rather than trusting callers: a transient whose
`GetRefCount()` is zero is not handle-owned, and converting it would hand OCCT `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 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 Covered by `tests/test_handles.py`, run both normally and under
`Standard_Transient*` to `PyObject*` (OCCT objects have no self-py slot). It `make test-asan`: wrapper identity across a round trip, refcount balance,
loses on the property that matters most here — nanobind's intrusive protocol null-handle ↔ `None`, and memory growth across repeated create/destroy
unifies the C++ count with the PyObject refcount, so `Py_INCREF` from a cycles.
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 ## 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 ⊕ **`__hash__` is bound; `__eq__` is not.** Upstream binds `__hash__` (TShape ⊕
Location) and leaves `__eq__` at Python's default identity comparison, so two 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 re-extracted copies of the same face hash equal but compare unequal. That's
looks like a bug and is load-bearing: `cad/topology/geom_memo.py` buckets on intentional upstream behaviour worth keeping as-is: code that dedupes shapes
`hash(face)` and disambiguates with `IsSame` *because* `==` cannot be trusted. by hash and double-checks with `IsSame` relies on `==` *not* being
Binding `__eq__` to `IsEqual` would quietly collapse entries that memo keeps trustworthy on its own.
apart. Hash *values* need not match upstream — only the semantics do.
**Sub-shapes are returned by value** (`OCP_RETURN_COPY`) from explorers, **Sub-shapes are returned by value** (`OCP_RETURN_COPY`) from explorers,
iterators, map lookups and history lists. A `TopoDS_Shape` is a small value 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 holding a handle to its TShape, so a copy is cheap and owns what it points
at. This makes the lifetime class that segfaulted a process-global face memo at — this rules out a class of lifetime bug where a cached wrapper outlives
unrepresentable. the structure it was explored from.
**Executing constructors are not bound.** `BRepAlgoAPI_*` gets a default **Executing constructors are not bound.** `BRepAlgoAPI_*` gets a default
constructor plus `SetArguments`/`SetTools`/`Build` — the two-argument forms run constructor plus `SetArguments`/`SetTools`/`Build` — the two-argument forms
the algorithm immediately, which is how a latent double-execution survived in run the algorithm immediately, which invites calling `Build()` a second time
the app for a while. and running the operation twice.
**`_s` on every static**, via `OCP_DEF_S`. The rule is blanket rather than **`_s` on every static**, via `OCP_DEF_S`. The rule is blanket, not
clash-driven, so no static can ship without it; the app calls 176 of them. clash-driven no static ships without it.
**Enums** use `nb::is_arithmetic()` + `export_values()`, reproducing pybind11's **Enums** use `nb::is_arithmetic()` + `.export_values()`, matching
int comparison and module-scope members (`from OCP.TopAbs import TopAbs_FACE`). pybind11's int comparison and module-scope members (`from OCP.TopAbs import
TopAbs_FACE`).
**Exceptions**: `Standard_Failure` derives `RuntimeError`, which is what keeps **Exceptions**: `Standard_Failure` derives `RuntimeError`, so `except
the backend's `except RuntimeError` sites working — it never names an OCCT RuntimeError` keeps working without ever naming an OCCT class. Around twenty
class. About 20 concrete types are bound under `OCP.Standard` / `OCP.StdFail` concrete exception types are also bound under `OCP.Standard` / `OCP.StdFail`
and dispatched on the dynamic OCCT type, because cad_pool's children marshal and dispatched on the OCCT dynamic type, for callers that want to match by
failures home as `f"{type(exc).__name__}: {exc}"`, making the name observable. name.
**`TopoDS` is a namespace in OCCT 7.9**, not a class. Upstream still presents it **`TopoDS` is a namespace in OCCT**, not a class. Upstream still presents it
as a class carrying the `_s` statics, and the app calls `TopoDS.Face_s(...)`, so as a class carrying the `_s` statics (`TopoDS.Face_s(...)`), so
`mod_TopoDS.cpp` binds an empty carrier struct under that name. `mod_TopoDS.cpp` binds an empty carrier struct under that name to match.
## GIL policy ## 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 `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 **STEP and IGES readers/writers are the exception.** They go through the
said before Inc 3 landed. STEP and IGES read and write through the process-global `Interface_Static` settings table, and IGES reading is
process-global `Interface_Static` settings table, the IGES reader is documented documented as not thread-safe. OCCT 8.0 added a thread-safety contract for
as not thread-safe, and the app already serialises every import behind a lock — XSTEP, but it only covers one reader/writer per thread using the *default*
so holding the GIL costs nothing there and removes a whole class of question. parameter set — it says nothing about a process that mutates
Upstream releases nowhere, so this also stays closer to it. `RWStl` touches no `Interface_Static` (a common way to configure units before reading). Holding
global state and does release. 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 `BinTools` releases the GIL for the kernel half of (de)serialisation.
assumed impossible. Rather than bridging a `streambuf` that calls back into Rather than bridging a `streambuf` that calls back into Python per chunk,
Python per chunk, `occt_stream.h` slurps the file-like object first and hands `occt_stream.h` reads the file-like object fully first and hands the kernel a
the kernel a pure C++ stream. That is correct regardless of how BinTools seeks, plain C++ stream — one extra copy of the payload, which `n3xd_ocp.bintools`
and costs one extra copy of the payload — which `n3xd_ocp.bintools` avoids avoids for callers that already have `bytes`.
entirely for the pool paths that care.
**An unregistered type cannot be a default argument.** nanobind converts **An unregistered type can't be a default argument.** nanobind converts
defaults to Python objects at *binding* time, so a `.def(..., "Algo"_a = default values to Python objects at *binding* time, so giving an enum this
Extrema_ExtAlgo_Grad)` for an enum this binding does not register fails the binding doesn't register as a default (e.g. `"Algo"_a =
whole extension's import with a bare `std::bad_cast` — no file, no line. It Extrema_ExtAlgo_Grad`) fails the whole extension's import with a bare
happened three times while writing Inc 1 and 2. Where the trailing argument is `std::bad_cast` and no further detail. Where the default is never overridden
one the app never overrides, the fix is to leave it off and let OCCT apply its in practice, the fix is to leave the argument off and let OCCT apply its own
own default: `GeomAPI_ProjectPointOnSurf` (Extrema algo), default `tools/sigdiff.py` reports every place the bound surface
`BRepFilletAPI_MakeFillet` (`ChFi3d_FilletShape`), deliberately differs from upstream this way.
`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 **`None` is rejected before the caster reaches simple overloads.** Handle
handle parameters that legitimately accept a null handle need an explicit parameters that legitimately accept a null handle need an explicit
`nb::arg("x").none()`. Null *returns* map to `None` unconditionally. The app `nb::arg("x").none()`. Null *returns* map to `None` unconditionally.
passes no null handles today; `inventory.py` plus the app suite are the guard.
## Packaging ## Packaging
**One extension** (`OCP/_OCP.abi3.so`) registering every `OCP.*` submodule via **One extension** (`OCP/_OCP.abi3.so`) registers every `OCP.*` submodule via
`PyImport_AddModule`, so `import OCP.TopoDS` works with no shim module per name `PyImport_AddModule`, so `import OCP.TopoDS` works without a shim module per
and `cls.__module__` reads `OCP.TopoDS`. The whole surface traffics in name. Types are shared in-process for free this way, instead of leaning on
`TopoDS_Shape`, `gp_*` and handles, so sharing types in-process is free here and nanobind's cross-extension registry. One `.cpp` file per module keeps
would otherwise lean on nanobind's cross-extension registry; registration order incremental compiles cheap — only the final link step is shared.
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 **abi3 (`cp312-abi3`)**, so bumping the Python version doesn't require a
Python 3.13 bump (roadmap 10D) needed no rebuild — the same wheel loads on the rebuild — the tag stays at `cp312` as a floor, not a target. Stub generation
3.13 the app now ships. The tag stays at `cp312`: it is a floor, and raising it works fine under abi3. If a nanobind STABLE_ABI limitation ever gets in the
would buy nothing. Escape hatch if a nanobind STABLE_ABI limitation ever bites: way, dropping `STABLE_ABI` and pinning to one interpreter is the escape
drop `STABLE_ABI` and `wheel.py-api`, since every deployed environment is on one hatch.
interpreter. Stub generation works fine under abi3.
**Version `<occt>.N`**, asserted at configure time against the OCCT actually **Version `<occt-version>.N`**, asserted at configure time against the OCCT
found, so `occt_version()` keeps reporting the truth for assay's goldens. actually found, so the version string always reflects the kernel underneath
Iteration builds carry `.devN`; the registry never allows republishing. 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, **No sdist is ever published** — it can't build without the builder image,
and offering one invites the 4-core production host to try. That is enforced by and shipping one just invites someone to try. `sdist.exclude` is *not* the
only ever building wheels. Note `sdist.exclude` is *not* the way to do it: way to enforce that: scikit-build-core also feeds it into the wheel's
scikit-build-core feeds it into the wheel's package-file mapping too, so package-file mapping, so excluding everything silently ships a wheel with
excluding `*` silently ships a wheel containing the compiled extension and none the compiled extension and none of the Python package.
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 **Fork safety**: importing the extension starts no threads and creates no
cad_pool's forkserver keeps costing ~30 ms per job instead of a ~1.3 s spawn. fork-hostile state, so forking right after import is cheap and safe. Pinned
Pinned by `tests/test_forksafety.py`. by `tests/test_forksafety.py`.
## The `n3xd_ocp` module ## The `n3xd_ocp` module
`OCP.*` stays a symbol-for-symbol drop-in so parity testing means something; `OCP.*` stays a symbol-for-symbol drop-in so parity testing against the
anything additive lives in `n3xd_ocp`, shipped in the same wheel and backed by stock wheel means something; anything additive lives in `n3xd_ocp` instead,
the same OCCT build. Only the leaf submodules are registered from C++ — creating shipped in the same wheel and built against the same OCCT. Only the leaf
the parent would put a bare module in `sys.modules` and a later `import submodules are registered from C++ — creating the parent package from C++
n3xd_ocp` would skip the package's `__init__.py`. 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 Shipped today: `bintools` (shape ↔ `bytes`, byte-identical to
`OCP.BinTools` and asserted so) and `_debug` (test-only ownership `OCP.BinTools`), `measure` (batched per-face area and centroid) and `tess`
introspection). (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 `inventory.py --check` answers "does the symbol exist", not "does it behave
# with Inc 1 (BRepGProp/GProp) — attacks the measured 94 % of the same" — and that gap is where a binding can do real damage silently.
# face_candidate_anchors (0.99 s of 1.05 s for 690 faces) that is `tools/sigdiff.py` (`make sigdiff`) closes it by diffing every bound
# BRepGProp.SurfaceProperties_s called once per face from Python constructor and member against the stock wheel; see
def face_surface_props(shape, *, parallel=True) -> tuple[ndarray, ndarray] [adding-symbols.md](adding-symbols.md) for the bug it exists to catch.
# 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 Two things static analysis can't see, worth keeping in mind:
# 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 - **Instance methods.** A method called on an object (`vec.Reverse()`)
# catchable Standard_Failure subclasses inside cad_pool children. Never called appears in no import, so `--check` is blind to it; `--methods` only
# at import; subprocess isolation stays regardless. guesses, by tracing local variables back to their constructor.
def set_signal(arm_fpe: bool = False) -> None - **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
The backend adopts these after cutover, one call site at a time. `Modified`/`Generated`/`IsDeleted` history maps — reproduced under this
build and compared exactly.
## 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.