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)
.secrets

117
README.md
View File

@@ -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 `<occt-version>.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 `<occt-version>.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
```

View File

@@ -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<N>_*.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

View File

@@ -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.

View File

@@ -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<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.
- **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<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.
constructor placement-news the object into the Python instance's storage,
which OCCT would later try to `delete` itself. Use `ocp_new<T, Args...>()`
(`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 `<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.
**Version `<occt-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<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.
- **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.