3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
117
README.md
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
109
docs/building.md
109
docs/building.md
@@ -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.
|
||||||
|
|||||||
369
docs/design.md
369
docs/design.md
@@ -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.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user