Phase 10A/10B Inc 0: build system, handle model, first module surface

Builds n3xd-ocp end to end and publishes 7.9.3.1.dev1 to the Gitea registry,
where it installs anonymously and passes its suite.

- occt/Dockerfile: OCCT 7.9.3 compiled once into a manylinux_2_28 builder
  image (base digest + tarball sha256 pinned), Draw/VTK/Tk/Xlib/OpenGL off,
  FreeType on, -O2 without fast-math or march=native. A final layer asserts
  TKService/TKV3d exist with no libGL/libX11 DT_NEEDED, which is what lets the
  app image drop libgl1/libx11-6. Mounted into, never built FROM.
- scikit-build-core + nanobind STABLE_ABI -> one cp312-abi3 extension that
  registers every OCP.* submodule via PyImport_AddModule, so `import
  OCP.TopoDS` needs no shim and cls.__module__ is right. Version <occt>.N is
  asserted against the OCCT found, keeping occt_version() truthful.
- occt_handle.h: type caster for opencascade::handle<T> over OCCT's intrusive
  refcount. Wrappers are non-owning instances holding exactly one handle in
  their keep-alive list, reusing an existing wrapper so identity survives a
  round trip. Transient constructors go through ocp_new (never nb::init<>,
  which would let OCCT delete nanobind's storage); the caster refuses a
  refcount-0 object rather than corrupt the heap. Verified under ASAN with no
  memory-safety errors, plus an RSS bound over 50k create/destroy cycles.
- Sub-shapes are returned by value everywhere, making the TShape lifetime class
  that segfaulted a process-global face memo unrepresentable.
- Standard_Failure derives RuntimeError, with ~20 concrete types dispatched on
  the dynamic OCCT type (cad_pool marshals failures home by type name).
- Inc 0 surface: gp subset, TopAbs, TopoDS (+ downcasts), TopExp, TopLoc,
  TopTools, BRep, BinTools, Poly, Standard. 34 of the app's 139 symbols.
- n3xd_ocp: additive APIs kept out of the OCP namespace so parity testing stays
  meaningful. bintools (shape <-> bytes, GIL-free, byte-identical) and _debug.

Two findings worth the record, both verified against the stock wheel rather
than assumed: upstream binds __hash__ but leaves __eq__ at identity, which is
exactly what geom_memo.py's hash-bucket + IsSame scan is built around, so we
match it instead of "fixing" it; and BinTools can release the GIL after all, by
slurping the file-like object instead of bridging a streambuf that would call
back into Python.

Gate: BREP round-trips are byte-identical to cadquery-ocp-novtk across six
fixtures (the generator asserts stock idempotency first). That matters beyond
IPC — derive.py content-addresses BREP payloads by sha256 and stores the ref.
This commit is contained in:
2026-08-10 16:10:28 +02:00
parent b757d6e8d6
commit 6139852768
58 changed files with 3446 additions and 13 deletions

1
.gitignore vendored
View File

@@ -22,6 +22,7 @@ CMakeUserPresets.json
*.dylib
*.pyd
python/OCP/*.pyi
python/n3xd_ocp/*.pyi
# Vendored or locally built OCCT (never committed; it comes from the
# builder image, and a kernel tree would dwarf this repo)

64
CMakeLists.txt Normal file
View File

@@ -0,0 +1,64 @@
cmake_minimum_required(VERSION 3.20)
project(n3xd_ocp LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release)
endif()
# Match the kernel's flags: -O2, no fast-math, no -march=native. OCCT's
# version is a determinism input for assay's committed goldens, so the binding
# has no business introducing a different FP contract than the kernel it wraps.
set(CMAKE_CXX_FLAGS_RELEASE "-O2 -DNDEBUG")
find_package(Python 3.12 REQUIRED
COMPONENTS Interpreter Development.Module Development.SABIModule)
find_package(nanobind CONFIG REQUIRED)
find_package(OpenCASCADE REQUIRED)
# The version scheme is enforced, not merely conventional: a wheel labelled
# 7.9.3.N must actually wrap OCCT 7.9.3.
set(_occt_ver "${OpenCASCADE_MAJOR_VERSION}.${OpenCASCADE_MINOR_VERSION}.${OpenCASCADE_MAINTENANCE_VERSION}")
if(DEFINED SKBUILD_PROJECT_VERSION)
if(NOT SKBUILD_PROJECT_VERSION MATCHES "^${_occt_ver}\\.")
message(FATAL_ERROR
"Version '${SKBUILD_PROJECT_VERSION}' does not match the OCCT found "
"(${_occt_ver}). The scheme is <occt-version>.N — see pyproject.toml.")
endif()
endif()
message(STATUS "Building n3xd-ocp against OCCT ${_occt_ver}")
file(GLOB OCP_MODULE_SOURCES CONFIGURE_DEPENDS "src/modules/*.cpp")
file(GLOB OCP_EXT_SOURCES CONFIGURE_DEPENDS "src/ext/*.cpp")
nanobind_add_module(_OCP
STABLE_ABI
NB_STATIC
src/core.cpp
src/common/occt_module.cpp
src/common/occt_exceptions.cpp
${OCP_MODULE_SOURCES}
${OCP_EXT_SOURCES}
)
target_include_directories(_OCP PRIVATE ${OpenCASCADE_INCLUDE_DIR})
# Only the toolkits the bound surface actually reaches. Grows with each
# coverage increment; auditwheel bundles whatever the linker records.
target_link_libraries(_OCP PRIVATE
TKernel
TKMath
TKG2d
TKG3d
TKGeomBase
TKBRep
TKTopAlgo
)
target_compile_definitions(_OCP PRIVATE
N3XD_OCP_VERSION="${SKBUILD_PROJECT_VERSION}"
)
install(TARGETS _OCP LIBRARY DESTINATION OCP)

56
Makefile Normal file
View File

@@ -0,0 +1,56 @@
OCCT_VER ?= 7.9.3
IMG_N ?= 1
IMAGE ?= git.stroblme.de/n3xd/occt-build:$(OCCT_VER)-$(IMG_N)
# Build cache lives off the root filesystem, which is tight on this host.
CACHE ?= /mnt/cache/n3xd/ocp
REPO := $(shell pwd)
PUBLISH_URL ?= https://git.stroblme.de/api/packages/N3XD/pypi
# The builder image is a compiler appliance: mounted into, never built FROM, so
# iterating on the binding never re-layers the kernel. $(CACHE) keeps the
# CMake tree and ccache between runs, which is what makes the inner loop a
# recompile of one TU rather than a full build.
DOCKER_RUN = docker run --rm -u $(shell id -u):$(shell id -g) \
-v $(REPO):/io -v $(CACHE):/cache -e CACHE_ROOT=/cache $(IMAGE)
.PHONY: help image image-push wheel dev shell test test-asan stubs publish clean clean-cache version parity
help:
@grep -E '^[a-z-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " %-14s %s\n", $$1, $$2}'
image: ## Build the OCCT builder image (~40 min; only when occt/Dockerfile changes)
docker build -f occt/Dockerfile -t $(IMAGE) occt/
image-push: ## Push the builder image (needs: docker login git.stroblme.de)
docker push $(IMAGE)
wheel: ## Build + repair + smoke-test the wheel into wheelhouse/
$(DOCKER_RUN) bash /io/scripts/build_wheel.sh
dev: ## Incremental build + run the test suite (the inner loop)
$(DOCKER_RUN) bash /io/scripts/in_container_test.sh
test: dev ## Alias for dev
shell: ## Interactive shell in the builder image
docker run --rm -it -v $(REPO):/io -v $(CACHE):/cache $(IMAGE) bash
stubs: ## Regenerate .pyi stubs from the built extension
$(DOCKER_RUN) bash /io/scripts/stubgen.sh
publish: ## Publish wheelhouse/*.whl to the Gitea package registry
@test -f .secrets || { echo "missing .secrets (UV_PUBLISH_USERNAME/PASSWORD)"; exit 1; }
set -a; . ./.secrets; set +a; \
uv publish --publish-url $(PUBLISH_URL) wheelhouse/*.whl
version: ## Print the wheel version
@grep '^version' pyproject.toml | head -1 | cut -d'"' -f2
clean: ## Remove build output (keeps the docker cache volume)
rm -rf build dist wheelhouse python/OCP/*.pyi python/n3xd_ocp/*.pyi
clean-cache: ## Drop the persistent build cache
rm -rf $(CACHE)/ccache $(CACHE)/skbuild $(CACHE)/pip
test-asan: ## Run the handle tests under AddressSanitizer
$(DOCKER_RUN) bash /io/scripts/asan.sh

View File

@@ -2,14 +2,24 @@
Hand-written [nanobind](https://github.com/wjakob/nanobind) bindings for the
OpenCASCADE (OCCT) geometry kernel, covering exactly the surface the N3XD CAD
backend uses — roughly 140 symbols across 48 `OCP.*` modules, not all of OCCT.
backend uses — 139 symbols across 48 `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 437 import sites stay untouched.
`cadquery-ocp-novtk` and the app's 442 import sites stay untouched.
Status: **not started.** The plan lives in the app repo at
`docs-private/reference/roadmap.md` (Phase 10), with the binding-layer
background in `docs-private/architecture/geometry-kernel.md`.
Status: **Inc 0 (spike) shipped** — build system, OCCT builder image, handle
model, and the first module surface (`gp`, `TopAbs`, `TopoDS`, `TopExp`,
`TopLoc`, `TopTools`, `BRep`, `BinTools`, `Poly`, `Standard`), published as
`7.9.3.1.dev1`. BREP serialisation is byte-identical to the stock wheel, which
is the gate that mattered: the pools and the content-addressed derive payloads
both depend on it. Coverage is 34 of the 139 symbols the app imports; the rest
lands in increments 1-4 (roadmap 10C).
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).
## Why
@@ -31,17 +41,24 @@ which upstream does.
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:
here on a dev box and published to the Gitea package registry.
```bash
uv publish --publish-url https://git.stroblme.de/api/packages/N3XD/pypi dist/*.whl
make image # once, ~40 min: compiles OCCT 7.9.3 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 — via
`https://git.stroblme.de/api/packages/N3XD/pypi/simple/`.
`UV_PUBLISH_PASSWORD`. Consumers read anonymously — the package is public:
Versions are `<occt-version>.N`, 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.
```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.

99
docs/adding-symbols.md Normal file
View File

@@ -0,0 +1,99 @@
# 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.
## 1. Find what is missing
```bash
python tools/inventory.py --emit # re-scan the app for OCP usage
python tools/inventory.py --check # what the installed wheel lacks, by module
```
`--check` groups gaps by module, which is how increments are scoped. Note it
only sees symbols reached through an import (`TopExp.MapShapes_s`), not methods
called on instances (`shape.IsSame(...)`) — a green `--check` is necessary, not
sufficient. Running the app's own tests in the parity venv is what catches the
rest, loudly, as an `AttributeError`.
## 2. Write the module
One file per OCP module: `src/modules/mod_<Name>.cpp`.
```cpp
#include "../common/occt_module.h" // brings in the handle caster and "_a"
#include "../common/occt_policies.h" // OCP_RETURN_COPY, OCP_NOGIL
#include <Some_Class.hxx>
void register_Some(nb::module_ &root) {
nb::module_ m = ocp_submodule(root, "Some");
nb::class_<Some_Class>(m, "Some_Class")
.def(nb::init<>())
.def("Value", &Some_Class::Value, "index"_a, OCP_RETURN_COPY)
.def("Build", &Some_Class::Build, OCP_NOGIL);
}
```
Declare and call `register_Some` in `src/core.cpp`. Registration order matters
only in that a base class must precede its derived classes.
## 3. The checklist
- **Shape-returning API** → `OCP_RETURN_COPY`. Explorers, iterators, map
lookups, `Generated`/`Modified` lists — anything handing out a reference into
storage the caller does not own.
- **Static method** → `OCP_DEF_S(cls, "Name", ...)`, which appends `_s`. Every
static, without exception.
- **Transient (handle-managed) class** → derive from `Standard_Transient` in the
`nb::class_` declaration and bind constructors with `ocp_new<T, Args...>()`.
Never `nb::init<>` — see design.md.
- **Long kernel call** → `OCP_NOGIL`, but only if it cannot re-enter Python.
- **Executing constructor** → do not bind it. Bind the default constructor plus
the `SetX`/`Build` sequence.
- **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.
- **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.
When in doubt about a signature, ask the stock wheel rather than guessing:
```bash
cd ../app && .venv/bin/python -c "from OCP.BRep import BRep_Tool; print(BRep_Tool.Triangulation_s.__doc__)"
```
## 4. New toolkits
If the linker cannot find a symbol, the class lives in a toolkit not yet listed
in `CMakeLists.txt` (`target_link_libraries(_OCP PRIVATE ...)`). Add it there;
`auditwheel` bundles whatever the linker records, so nothing else changes.
## 5. Verify and ship
```bash
make dev # compile + tests
make test-asan # if you touched ownership or added transients
make wheel # bump the .devN in pyproject.toml first
make publish
tools/parity_venv.sh && python tools/inventory.py --check
```
Then run the slice of the app's suite the increment claims — Inc 1 is gated on
`backend/tests/test_geom_memo.py`, Inc 2 on the tessellation tests plus
`pytest -m perf`, Inc 4 on `test_cad_pool.py` and `test_derive.py`, and the
cutover on the full suite plus `backend/tools/rebuild_sweep.py --diff` over the
project store.
## Adding to `n3xd_ocp` instead
Anything that is not a faithful mirror of an upstream symbol belongs in
`src/ext/` under the `n3xd_ocp` namespace: bulk array APIs, batched measurement,
anything GIL-free that upstream does not offer. `OCP.*` staying a
symbol-for-symbol drop-in is what makes parity testing meaningful, so keep
additive work out of it. Register leaf modules with
`ocp_named_module("n3xd_ocp.<name>")` and re-export them in
`python/n3xd_ocp/__init__.py`.

110
docs/building.md Normal file
View File

@@ -0,0 +1,110 @@
# 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`.
## The builder image
`occt/Dockerfile` compiles OCCT 7.9.3 once inside
`quay.io/pypa/manylinux_2_28_x86_64` (both the base digest and the source
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
```
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.
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.
## 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`.
## The loop
```bash
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` 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.
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
self-contained.
## Fixtures
`tests/data/*.brep` are the byte-identity references and are generated under the
**stock** wheel, from the app checkout:
```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 (e.g. the OCCT 8.0 bump).
## Publishing
```bash
make version # confirm what you are 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`.
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
`pyproject.toml` for each upload.
Consumers read anonymously — the N3XD org 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.
## 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.
```bash
tools/parity_venv.sh # from the registry
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.

195
docs/design.md Normal file
View File

@@ -0,0 +1,195 @@
# Design record
Decisions that are expensive to revisit, and the evidence behind them. If you
are adding symbols rather than changing the machinery, read
[adding-symbols.md](adding-symbols.md) instead.
## What this is
A hand-written [nanobind](https://github.com/wjakob/nanobind) binding of the
OCCT surface the N3XD backend actually uses — 139 symbols across 48 `OCP.*`
modules, per `tools/inventory.py`, not all of OCCT. It installs as a top-level
`OCP`, so it is a drop-in replacement for `cadquery-ocp-novtk` and the app's 442
import sites stay untouched.
Binding *call* overhead was never the bottleneck — the CAD hotspots are inside
the C++ kernel — so the payoff is version velocity, footprint, correctness at
the ownership boundary, and the freedom to add APIs upstream cannot (GIL
release, bulk array extraction).
## The handle model
`opencascade::handle<T>` is cast by `src/common/occt_handle.h`, modelled on
nanobind's own `stl/shared_ptr.h`.
- **C++ → Python**: the wrapper is a *non-owning* nanobind instance pointing at
the C++ object, plus one handle stored in its keep-alive list. An existing
wrapper is reused (`is_new == false`), so identity holds while a wrapper is
alive, and a long-lived object crossing the boundary repeatedly does not pile
up redundant references. Transients are polymorphic, so `nb_type_put_p`
downcasts on the dynamic type.
- **Python → C++**: a plain handle copy, balanced when the caster dies after the
call. Unlike `shared_ptr`, no Python reference is taken: OCCT's intrusive
atomic refcount owns the object's memory, not the Python instance. That is
precisely what makes releasing the GIL safe — OCCT may copy handles on its own
worker threads without touching the interpreter.
**Transient constructors never use `nb::init<>`.** nanobind's normal
constructor placement-news the object into the Python instance's storage, which
OCCT would later `delete`. Use `ocp_new<T, Args...>()` (`occt_transient.h`),
which heap-allocates and returns a handle. This is not hypothetical: the app
builds a `Geom_BSplineCurve` in `sketch_builder/edges.py` and hands it to
`BRepBuilderAPI_MakeEdge`, which keeps a handle past the call.
The caster enforces the rule rather than trusting it: a transient whose
`GetRefCount()` is zero is not handle-owned, and converting it would hand OCCT
the right to free a nanobind instance. `from_python` refuses instead — a
TypeError beats heap corruption, and only a binding bug can reach it.
*Alternative considered*: `nb::intrusive_ptr` plus a side table mapping
`Standard_Transient*` to `PyObject*` (OCCT objects have no self-py slot). It
loses on the property that matters most here — nanobind's intrusive protocol
unifies the C++ count with the PyObject refcount, so `Py_INCREF` from a
GIL-free OCCT thread becomes a crash class. Kept as the documented fallback if
the caster above ever proves unworkable.
*Verification*: `tests/test_handles.py`, run both normally and under
`make test-asan`. It covers wrapper identity across a round trip, survival in
both directions when one side drops its reference, refcount balance across 1000
conversions and across 100 raising calls, null-handle ↔ `None`, and RSS growth
across 50 000 create/destroy cycles. ASAN covers memory *safety*; the RSS
assertion covers *growth* and is skipped under ASAN, whose quarantine retains
freed memory (139 MB of it, which is what a naive reading would call a leak).
## Fidelity rules
Verified against the installed stock wheel, not assumed.
**`__hash__` is bound; `__eq__` is not.** Upstream binds `__hash__` (TShape ⊕
Location) and leaves `__eq__` at Python's default identity comparison, so two
re-extracted copies of one face hash equal but compare unequal. That pairing
looks like a bug and is load-bearing: `cad/topology/geom_memo.py` buckets on
`hash(face)` and disambiguates with `IsSame` *because* `==` cannot be trusted.
Binding `__eq__` to `IsEqual` would quietly collapse entries that memo keeps
apart. Hash *values* need not match upstream — only the semantics do.
**Sub-shapes are returned by value** (`OCP_RETURN_COPY`) from explorers,
iterators, map lookups and history lists. A `TopoDS_Shape` is a small value
holding a handle to its TShape, so a copy is one incref and owns what it points
at. This makes the lifetime class that segfaulted a process-global face memo
unrepresentable.
**Executing constructors are not bound.** `BRepAlgoAPI_*` gets a default
constructor plus `SetArguments`/`SetTools`/`Build` — the two-argument forms run
the algorithm immediately, which is how a latent double-execution survived in
the app for a while.
**`_s` on every static**, via `OCP_DEF_S`. The rule is blanket rather than
clash-driven, so no static can ship without it; the app calls 176 of them.
**Enums** use `nb::is_arithmetic()` + `export_values()`, reproducing pybind11's
int comparison and module-scope members (`from OCP.TopAbs import TopAbs_FACE`).
**Exceptions**: `Standard_Failure` derives `RuntimeError`, which is what keeps
the backend's `except RuntimeError` sites working — it never names an OCCT
class. About 20 concrete types are bound under `OCP.Standard` / `OCP.StdFail`
and dispatched on the dynamic OCCT type, because cad_pool's children marshal
failures home as `f"{type(exc).__name__}: {exc}"`, making the name observable.
**`TopoDS` is a namespace in OCCT 7.9**, not a class. Upstream still presents it
as a class carrying the `_s` statics, and the app calls `TopoDS.Face_s(...)`, so
`mod_TopoDS.cpp` binds an empty carrier struct under that name.
## GIL policy
Released around calls that stay inside the kernel and cannot re-enter Python:
`Build`/`Perform`, meshing, `BRepCheck_Analyzer`, file readers and writers, and
every `n3xd_ocp` bulk API. Applied from an explicit list, never blanket.
`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.
**None as an argument** is rejected before the caster for simple overloads, so
handle parameters that legitimately accept a null handle need an explicit
`nb::arg("x").none()`. Null *returns* map to `None` unconditionally. The app
passes no null handles today; `inventory.py` plus the app suite are the guard.
## Packaging
**One extension** (`OCP/_OCP.abi3.so`) registering every `OCP.*` submodule via
`PyImport_AddModule`, so `import OCP.TopoDS` works with no shim module per name
and `cls.__module__` reads `OCP.TopoDS`. The whole surface traffics in
`TopoDS_Shape`, `gp_*` and handles, so sharing types in-process is free here and
would otherwise lean on nanobind's cross-extension registry; registration order
stays an explicit sequence in `core.cpp`; and cad_pool's forkserver warms one
dlopen. One `.cpp` per module keeps incremental compiles cheap — only the link
is shared.
**abi3 (`cp312-abi3`)** from the first build, matching forge/assay, so the
Python 3.13 bump (roadmap 10D) needs no rebuild. Escape hatch if a nanobind
STABLE_ABI limitation ever bites: drop `STABLE_ABI` and `wheel.py-api`, since
every deployed environment is 3.12. Stub generation works fine under abi3.
**Version `<occt>.N`**, asserted at configure time against the OCCT actually
found, so `occt_version()` keeps reporting the truth for assay's goldens.
Iteration builds carry `.devN`; the registry never allows republishing.
**No sdist is ever published** — it could not build without the builder image,
and offering one invites the 4-core production host to try. That is enforced by
only ever building wheels. Note `sdist.exclude` is *not* the way to do it:
scikit-build-core feeds it into the wheel's package-file mapping too, so
excluding `*` silently ships a wheel containing the compiled extension and none
of the Python package. Relatedly, file selection is git-based, so the generated
`.pyi` stubs are gitignored *and* re-included through `sdist.include`.
**Fork safety**: import starts no threads and creates no fork-hostile state, so
cad_pool's forkserver keeps costing ~30 ms per job instead of a ~1.3 s spawn.
Pinned by `tests/test_forksafety.py`.
## The `n3xd_ocp` module
`OCP.*` stays a symbol-for-symbol drop-in so parity testing means something;
anything additive lives in `n3xd_ocp`, shipped in the same wheel and backed by
the same OCCT build. Only the leaf submodules are registered from C++ — creating
the parent would put a bare module in `sys.modules` and a later `import
n3xd_ocp` would skip the package's `__init__.py`.
Shipped: `bintools` (shape ↔ `bytes`, GIL-free, byte-identical to
`OCP.BinTools` and asserted so) and `_debug` (test-only ownership
introspection).
Designed, landing with the increment that binds their types:
```python
# with Inc 1 (BRepGProp/GProp) — attacks the measured 94 % of
# face_candidate_anchors (0.99 s of 1.05 s for 690 faces) that is
# BRepGProp.SurfaceProperties_s called once per face from Python
def face_surface_props(shape, *, parallel=True) -> tuple[ndarray, ndarray]
# areas[F], centroids[F,3], ordered by TopExp.MapShapes_s(FACE) index
# with Inc 2 (BRepMesh/Poly) — replaces the per-node and per-triangle Python
# loops in cad/tessellation.py
class FaceMesh: nodes, triangles, normals, uv, face_index
def extract_meshes(shape, *, want_normals=True, want_uv=False,
apply_location=True, flip_reversed=True) -> list[FaceMesh]
# with Inc 4 — opt-in experiment: OSD::SetSignal turns some native faults into
# catchable Standard_Failure subclasses inside cad_pool children. Never called
# at import; subprocess isolation stays regardless.
def set_signal(arm_fpe: bool = False) -> None
```
The backend adopts these after cutover, one call site at a time.
## Open questions
- **S5** — whether STEP needs `CSF_*` resource files shipped in the wheel.
Modern OCCT code-initialises most `Interface_Static` defaults; settle it when
Inc 3 (I/O) lands, in a container without system OCCT resources.
- **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.

291
inventory.json Normal file
View File

@@ -0,0 +1,291 @@
{
"app_root": "/home/stroblme/n3xd/app/backend",
"files_importing_ocp": 115,
"modules": {
"BOPAlgo": {
"BOPAlgo_GlueShift": []
},
"BRep": {
"BRep_Builder": [],
"BRep_Tool": [
"Degenerated_s",
"Pnt_s",
"Surface_s",
"Tolerance_s",
"Triangulation_s"
]
},
"BRepAdaptor": {
"BRepAdaptor_Curve": [],
"BRepAdaptor_Surface": []
},
"BRepAlgoAPI": {
"BRepAlgoAPI_Common": [],
"BRepAlgoAPI_Cut": [],
"BRepAlgoAPI_Fuse": [],
"BRepAlgoAPI_Section": [],
"BRepAlgoAPI_Splitter": []
},
"BRepBndLib": {
"BRepBndLib": [
"Add_s"
]
},
"BRepBuilderAPI": {
"BRepBuilderAPI_Copy": [],
"BRepBuilderAPI_MakeEdge": [],
"BRepBuilderAPI_MakeFace": [],
"BRepBuilderAPI_MakePolygon": [],
"BRepBuilderAPI_MakeWire": [],
"BRepBuilderAPI_Sewing": [],
"BRepBuilderAPI_Transform": []
},
"BRepCheck": {
"BRepCheck_Analyzer": [],
"BRepCheck_Status": [
"BRepCheck_IntersectingWires",
"BRepCheck_SelfIntersectingWire"
]
},
"BRepClass3d": {
"BRepClass3d_SolidClassifier": []
},
"BRepExtrema": {
"BRepExtrema_DistShapeShape": []
},
"BRepFilletAPI": {
"BRepFilletAPI_MakeChamfer": [],
"BRepFilletAPI_MakeFillet": []
},
"BRepGProp": {
"BRepGProp": [
"LinearProperties_s",
"SurfaceProperties_s",
"VolumeProperties_s"
]
},
"BRepLib": {
"BRepLib": [
"BuildCurves3d_s",
"OrientClosedSolid_s"
]
},
"BRepMesh": {
"BRepMesh_IncrementalMesh": []
},
"BRepOffsetAPI": {
"BRepOffsetAPI_DraftAngle": [],
"BRepOffsetAPI_MakePipe": [],
"BRepOffsetAPI_MakePipeShell": [],
"BRepOffsetAPI_MakeThickSolid": [],
"BRepOffsetAPI_ThruSections": []
},
"BRepPrimAPI": {
"BRepPrimAPI_MakeBox": [],
"BRepPrimAPI_MakeCone": [],
"BRepPrimAPI_MakeCylinder": [],
"BRepPrimAPI_MakeHalfSpace": [],
"BRepPrimAPI_MakePrism": [],
"BRepPrimAPI_MakeRevol": []
},
"BRepTools": {
"BRepTools": [
"Clean_s",
"OuterWire_s",
"UVBounds_s"
],
"BRepTools_WireExplorer": []
},
"BinTools": {
"BinTools": [
"Read_s",
"Write_s"
]
},
"Bnd": {
"Bnd_Box": []
},
"GC": {
"GC_MakeArcOfCircle": []
},
"GCE2d": {
"GCE2d_MakeLine": [],
"GCE2d_MakeSegment": []
},
"GCPnts": {
"GCPnts_AbscissaPoint": [
"Length_s"
],
"GCPnts_TangentialDeflection": [],
"GCPnts_UniformDeflection": []
},
"GProp": {
"GProp_GProps": []
},
"Geom": {
"Geom_BSplineCurve": [],
"Geom_CylindricalSurface": [],
"Geom_OffsetSurface": []
},
"GeomAPI": {
"GeomAPI_PointsToBSplineSurface": [],
"GeomAPI_ProjectPointOnSurf": []
},
"GeomAbs": {
"GeomAbs_BSplineCurve": [],
"GeomAbs_BSplineSurface": [],
"GeomAbs_BezierCurve": [],
"GeomAbs_BezierSurface": [],
"GeomAbs_C2": [],
"GeomAbs_Circle": [],
"GeomAbs_Cone": [],
"GeomAbs_Cylinder": [],
"GeomAbs_Ellipse": [],
"GeomAbs_Line": [],
"GeomAbs_Plane": [],
"GeomAbs_Sphere": [],
"GeomAbs_Torus": []
},
"GeomLProp": {
"GeomLProp_SLProps": []
},
"Graphic3d": {
"Graphic3d_HTA_CENTER": [],
"Graphic3d_HTA_LEFT": [],
"Graphic3d_HTA_RIGHT": [],
"Graphic3d_VTA_BOTTOM": [],
"Graphic3d_VTA_CENTER": [],
"Graphic3d_VTA_TOP": []
},
"IFSelect": {
"IFSelect_RetDone": []
},
"IGESControl": {
"IGESControl_Controller": [
"Init_s"
],
"IGESControl_Reader": [],
"IGESControl_Writer": []
},
"IntCurvesFace": {
"IntCurvesFace_ShapeIntersector": []
},
"Interface": {
"Interface_Static": [
"CVal_s",
"SetCVal_s"
]
},
"NCollection": {
"NCollection_Utf8String": []
},
"Poly": {
"Poly_Triangulation": []
},
"RWStl": {
"RWStl": [
"ReadFile_s"
]
},
"STEPControl": {
"STEPControl_AsIs": [],
"STEPControl_Controller": [
"Init_s"
],
"STEPControl_Reader": [],
"STEPControl_Writer": []
},
"ShapeAnalysis": {
"ShapeAnalysis_FreeBounds": [
"ConnectEdgesToWires_s"
]
},
"ShapeFix": {
"ShapeFix_Face": [],
"ShapeFix_Shape": [],
"ShapeFix_Solid": []
},
"ShapeUpgrade": {
"ShapeUpgrade_UnifySameDomain": []
},
"StdPrs": {
"StdPrs_BRepFont": [],
"StdPrs_BRepTextBuilder": []
},
"StlAPI": {
"StlAPI_Writer": []
},
"TColStd": {
"TColStd_Array1OfInteger": [],
"TColStd_Array1OfReal": [],
"TColStd_SequenceOfAsciiString": []
},
"TColgp": {
"TColgp_Array1OfPnt": [],
"TColgp_Array2OfPnt": []
},
"TopAbs": {
"TopAbs_EDGE": [],
"TopAbs_FACE": [],
"TopAbs_IN": [],
"TopAbs_ON": [],
"TopAbs_OUT": [],
"TopAbs_REVERSED": [],
"TopAbs_SHELL": [],
"TopAbs_SOLID": [],
"TopAbs_ShapeEnum": [
"TopAbs_SHELL"
],
"TopAbs_VERTEX": [],
"TopAbs_WIRE": []
},
"TopExp": {
"TopExp": [
"MapShapesAndAncestors_s",
"MapShapes_s"
],
"TopExp_Explorer": []
},
"TopLoc": {
"TopLoc_Location": []
},
"TopTools": {
"TopTools_HSequenceOfShape": [],
"TopTools_IndexedDataMapOfShapeListOfShape": [],
"TopTools_IndexedMapOfShape": [],
"TopTools_ListOfShape": []
},
"TopoDS": {
"TopoDS": [
"Edge_s",
"Face_s",
"Shell_s",
"Solid_s",
"Vertex_s",
"Wire_s"
],
"TopoDS_Compound": [],
"TopoDS_Edge": [],
"TopoDS_Face": [],
"TopoDS_Shape": [],
"TopoDS_Shell": [],
"TopoDS_Wire": []
},
"gp": {
"gp_Ax1": [],
"gp_Ax2": [],
"gp_Ax3": [],
"gp_Circ": [],
"gp_Dir": [],
"gp_Dir2d": [],
"gp_Identity": [],
"gp_Lin": [],
"gp_Pln": [],
"gp_Pnt": [],
"gp_Pnt2d": [],
"gp_Quaternion": [],
"gp_Trsf": [],
"gp_Vec": []
}
}
}

79
occt/Dockerfile Normal file
View File

@@ -0,0 +1,79 @@
# OCCT builder image — a compiler appliance, not a base for the wheel.
#
# Wheel builds `docker run` this with the repo mounted (see ../Makefile); they
# never `FROM` it, so iterating on the binding never re-layers the kernel.
# Rebuild only when this file changes, and bump the image tag's -N when you do.
#
# The production host (4 cores) can never compile OCCT, which is the whole
# reason this exists: the kernel is compiled once here and shipped inside the
# wheel by `auditwheel repair`.
FROM quay.io/pypa/manylinux_2_28_x86_64@sha256:f854c50adf7b7a325bc4794316f3758d387a41d61f9e2ebca0f26c7dc8f761d4
# FreeType + fontconfig are the only optional OCCT dependencies we keep — the
# text-emboss feature builds glyph outlines through Font_BRepFont (TKService).
# ccache/ninja serve the wheel build that runs in this image later.
RUN dnf install -y freetype-devel fontconfig-devel ninja-build ccache valgrind \
&& dnf clean all
ARG OCCT_TAG=V7_9_3
ARG OCCT_SHA256=5ecf094ec6b12d5413dfb851d8c3590c354058aee556e32e408bdfbf8c357d57
RUN curl -fsSL -o /tmp/occt.tar.gz \
"https://github.com/Open-Cascade-SAS/OCCT/archive/refs/tags/${OCCT_TAG}.tar.gz" \
&& echo "${OCCT_SHA256} /tmp/occt.tar.gz" | sha256sum -c - \
&& mkdir -p /src \
&& tar -xzf /tmp/occt.tar.gz -C /src --strip-components=1 \
&& rm /tmp/occt.tar.gz
# Conservative FP flags are deliberate: OCCT's version is a determinism input
# for assay's committed goldens, so a different optimizer could shift last-ulp
# results against the upstream wheel we are proving parity with. -O2 (not the
# Release default -O3), no -ffast-math, no -march=native.
RUN cmake -G Ninja -S /src -B /build \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_FLAGS_RELEASE="-O2 -DNDEBUG" \
-DCMAKE_C_FLAGS_RELEASE="-O2 -DNDEBUG" \
-DBUILD_LIBRARY_TYPE=Shared \
-DINSTALL_DIR=/opt/occt \
-DBUILD_MODULE_FoundationClasses=ON \
-DBUILD_MODULE_ModelingData=ON \
-DBUILD_MODULE_ModelingAlgorithms=ON \
-DBUILD_MODULE_Visualization=ON \
-DBUILD_MODULE_DataExchange=ON \
-DBUILD_MODULE_ApplicationFramework=ON \
-DBUILD_MODULE_Draw=OFF \
-DBUILD_MODULE_DETools=OFF \
-DUSE_FREETYPE=ON \
-DUSE_VTK=OFF -DUSE_TK=OFF -DUSE_TCL=OFF \
-DUSE_XLIB=OFF -DUSE_OPENGL=OFF -DUSE_GLES2=OFF \
-DUSE_TBB=OFF -DUSE_RAPIDJSON=OFF -DUSE_DRACO=OFF \
-DUSE_FREEIMAGE=OFF -DUSE_FFMPEG=OFF -DUSE_OPENVR=OFF \
&& ninja -C /build -j"$(nproc)" \
&& ninja -C /build install \
&& find /opt/occt/lib -name '*.so*' -type f -exec strip --strip-debug {} + \
&& rm -rf /build /src
# Assert the headless build actually produced what the app needs. Text emboss
# imports OCP.StdPrs.StdPrs_BRepFont, which is a deprecated typedef of
# Font_BRepFont living in TKService/TKV3d — those toolkits must exist even with
# OpenGL and Xlib off, and must not have picked up a libGL/libX11 DT_NEEDED
# (dropping those two runtime packages from the app image is a phase goal).
RUN test -f /opt/occt/lib/libTKService.so \
&& test -f /opt/occt/lib/libTKV3d.so \
&& test -f /opt/occt/lib/libTKDESTEP.so \
&& ! ldd /opt/occt/lib/libTKService.so | grep -qE 'libGL\.|libX11\.' \
&& ! ldd /opt/occt/lib/libTKV3d.so | grep -qE 'libGL\.|libX11\.' \
&& ldd /opt/occt/lib/libTKService.so | grep -q libfreetype
ENV OCCT_ROOT=/opt/occt \
CMAKE_PREFIX_PATH=/opt/occt \
LD_LIBRARY_PATH=/opt/occt/lib \
PATH=/opt/python/cp312-cp312/bin:$PATH
WORKDIR /io
# Sanitizer runtime for `make test-asan`, the handle-model memory-safety check.
# Deliberately the last layer: adding it never invalidates the kernel build
# above, so the ~40 minute compile is not repeated for a test dependency.
RUN dnf install -y gcc-toolset-14-libasan-devel && dnf clean all

46
pyproject.toml Normal file
View File

@@ -0,0 +1,46 @@
[build-system]
requires = ["scikit-build-core>=0.11", "nanobind>=2.7"]
build-backend = "scikit_build_core.build"
[project]
name = "n3xd-ocp"
# <occt-version>.N, so the kernel a wheel wraps is readable from its version
# alone and occt_version() keeps reporting the truth. Iteration builds carry a
# .devN suffix: the registry never allows republishing a version, and its
# cleanup rule collects only the dev ones. CMake asserts the prefix matches
# the OCCT it found.
version = "7.9.3.1.dev1"
description = "nanobind bindings for the OpenCASCADE geometry kernel (drop-in OCP)"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "LGPL-2.1-only" }
authors = [{ name = "N3XD" }]
classifiers = [
"Private :: Do Not Upload",
"Programming Language :: Python :: 3.12",
"Programming Language :: C++",
]
[project.urls]
Repository = "https://git.stroblme.de/N3XD/ocp"
[tool.scikit-build]
minimum-version = "0.11"
build-dir = "build/{wheel_tag}"
wheel.packages = ["python/OCP", "python/n3xd_ocp"]
# Stubs are generated during the build and gitignored, and file selection is
# git-based by default — so they must be re-included by hand or the wheel ships
# without the typing surface that retires the backend's ty suppression.
sdist.include = ["python/OCP/*.pyi", "python/n3xd_ocp/*.pyi"]
# abi3 from day one, matching the forge/assay convention: one wheel spans 3.12
# and 3.13, so the interpreter bump (roadmap 10D) needs no rebuild here.
wheel.py-api = "cp312"
# No sdist is ever published — it could not build without the OCCT builder
# image, and offering one would invite the production host (4 cores) to try.
# That is enforced by only ever building wheels (scripts/build_wheel.sh) rather
# than by an exclude rule here: scikit-build-core feeds sdist.exclude into the
# wheel's package-file mapping too, so excluding "*" silently ships a wheel
# with the compiled extension and none of the Python package.
[tool.pytest.ini_options]
testpaths = ["tests"]

25
python/OCP/__init__.py Normal file
View File

@@ -0,0 +1,25 @@
"""OCP — Python bindings for the OpenCASCADE (OCCT) geometry kernel.
Installs under the same import path as ``cadquery-ocp``/``cadquery-ocp-novtk``
so it is a drop-in replacement; the two can never be installed together, and a
process can only ever hold one OCCT build.
The compiled extension registers every ``OCP.*`` submodule directly in
``sys.modules``, so ``import OCP.TopoDS`` works without a shim module per name.
This file only re-exports them as attributes, so ``OCP.TopoDS`` also resolves
after a bare ``import OCP``.
"""
from __future__ import annotations
import sys
from . import _OCP
__version__ = _OCP.__version__
__occt_version__ = _OCP.__occt_version__
for _name in _OCP.__all_modules__:
setattr(sys.modules[__name__], _name, sys.modules[f"OCP.{_name}"])
__all__ = [*_OCP.__all_modules__, "__version__", "__occt_version__"]

0
python/OCP/py.typed Normal file
View File

View File

@@ -0,0 +1,21 @@
"""n3xd_ocp — performance APIs that go beyond the upstream OCP surface.
Kept separate from ``OCP`` on purpose: that namespace stays a symbol-for-symbol
drop-in for ``cadquery-ocp-novtk`` so parity testing means something, while
anything additive lives here. Shipped in the same wheel, backed by the same
OCCT build, and safe to mix with ``OCP`` objects in one process.
Submodules are registered by the compiled extension; importing ``OCP`` is what
loads it.
"""
from __future__ import annotations
import sys
import OCP as _OCP_pkg # noqa: F401 (loads the extension, registering us)
bintools = sys.modules["n3xd_ocp.bintools"]
_debug = sys.modules["n3xd_ocp._debug"]
__all__ = ["bintools"]

0
python/n3xd_ocp/py.typed Normal file
View File

28
scripts/_env.sh Executable file
View File

@@ -0,0 +1,28 @@
# Shared environment for the in-container scripts. Sourced, not executed.
#
# Everything expensive or bulky (ccache, the CMake tree, pip's cache, the build
# venv) lives under $CACHE, which the Makefile bind-mounts from outside the
# repo — the root filesystem on this host has little headroom, and these
# survive between runs to keep the inner loop incremental.
CACHE="${CACHE_ROOT:-/cache}"
export CCACHE_DIR="$CACHE/ccache"
export PIP_CACHE_DIR="$CACHE/pip"
export HOME="$CACHE" # containers run as the host uid; /root is not ours
export CMAKE_C_COMPILER_LAUNCHER=ccache
export CMAKE_CXX_COMPILER_LAUNCHER=ccache
export CMAKE_PREFIX_PATH=/opt/occt
export LD_LIBRARY_PATH=/opt/occt/lib
export SKBUILD_BUILD_DIR="$CACHE/skbuild/{wheel_tag}"
mkdir -p "$CCACHE_DIR" "$PIP_CACHE_DIR"
VENV="$CACHE/venv"
if [ ! -x "$VENV/bin/python" ]; then
echo "--- creating build venv at $VENV ---"
/opt/python/cp312-cp312/bin/python -m venv "$VENV"
"$VENV/bin/pip" install -q --upgrade pip
"$VENV/bin/pip" install -q build nanobind scikit-build-core pytest
fi
PY="$VENV/bin/python"
export PY VENV CACHE

49
scripts/asan.sh Executable file
View File

@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Memory-safety check for the handle model.
#
# ASAN's leak detector is not usable here — CPython's arenas and OCCT's
# process-lifetime singletons produce noise that would drown a real finding —
# so this run targets *safety*: use-after-free, double-free, buffer overflow.
# That is exactly the failure mode a wrong ownership rule produces (a stale
# TShape is what segfaulted a process-global face memo under upstream OCP).
# Leak *growth* is checked separately, by the RSS assertion in test_handles.py.
set -euo pipefail
cd /io
. scripts/_env.sh
BUILD="$CACHE/asan"
mkdir -p "$BUILD"
echo "--- configuring ASAN build ---"
cmake -G Ninja -S . -B "$BUILD" \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DCMAKE_CXX_FLAGS="-fsanitize=address -fno-omit-frame-pointer -g" \
-DCMAKE_SHARED_LINKER_FLAGS="-fsanitize=address" \
-DPython_EXECUTABLE="$PY" \
-Dnanobind_DIR="$("$PY" -c 'import nanobind; print(nanobind.cmake_dir())')" \
>/dev/null
cmake --build "$BUILD" -j"$(nproc)"
STAGE=$(mktemp -d)
trap 'rm -rf "$STAGE"' EXIT
cp -r python/OCP python/n3xd_ocp tests "$STAGE/"
cp "$BUILD"/_OCP*.so "$STAGE/OCP/"
cp -r tests/data "$STAGE/tests/" 2>/dev/null || true
echo "--- running handle tests under ASAN ---"
cd "$STAGE"
# libasan.so is a linker script, not an ELF object — preloading it is silently
# ignored and the run would look clean while ASAN was never active. Resolve
# the real soname instead, and fail loudly if it is missing.
ASAN_LIB=$(gcc -print-file-name=libasan.so.8)
[ -f "$ASAN_LIB" ] || { echo "libasan.so.8 not found ($ASAN_LIB)" >&2; exit 1; }
head -c 4 "$ASAN_LIB" | grep -q ELF || { echo "$ASAN_LIB is not an ELF object" >&2; exit 1; }
LD_PRELOAD="$ASAN_LIB" \
N3XD_OCP_ASAN=1 \
ASAN_OPTIONS="detect_leaks=0:abort_on_error=1:strict_string_checks=1:detect_stack_use_after_return=1" \
"$PY" -m pytest tests/test_handles.py tests/test_shape_identity.py -q
echo "ASAN: no memory-safety errors"

41
scripts/build_wheel.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Build the wheel. Runs INSIDE the OCCT builder image (see ../Makefile), which
# supplies the kernel at /opt/occt and cp312 at /opt/python.
set -euo pipefail
cd /io
. scripts/_env.sh
echo "--- build (pass 1: compile) ---"
rm -rf dist wheelhouse
mkdir -p wheelhouse
# --no-isolation keeps the build in the cached venv so the CMake tree and
# ccache are actually reused between runs.
"$PY" -m build --wheel --no-isolation --outdir dist
# Stubs are generated by importing the freshly built extension, so they cannot
# exist before the first compile — and the wheel is packed from the source
# tree, so they would miss this wheel and ship one build stale. Pass 2 repacks
# with them present; it is incremental (same CMake tree, warm ccache).
echo "--- stubs ---"
scripts/stubgen.sh
echo "--- build (pass 2: repack with stubs) ---"
rm -rf dist
"$PY" -m build --wheel --no-isolation --outdir dist
echo "--- auditwheel repair ---"
auditwheel repair --plat manylinux_2_28_x86_64 -w wheelhouse dist/*.whl
echo "--- smoke test (no LD_LIBRARY_PATH: proves the wheel is self-contained) ---"
rm -rf "$CACHE/smoke"
"$PY" -m venv "$CACHE/smoke"
"$CACHE/smoke/bin/pip" install -q wheelhouse/*.whl
env -u LD_LIBRARY_PATH "$CACHE/smoke/bin/python" -c "
import OCP, n3xd_ocp
from OCP.TopoDS import TopoDS_Shape
print('OCP', OCP.__version__, '/ OCCT', OCP.__occt_version__)
print('modules:', ' '.join(OCP._OCP.__all_modules__))
"
ls -la wheelhouse/

16
scripts/in_container_test.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Inner development loop: incremental compile, then the test suite.
#
# Installs into the cached venv rather than building a wheel, so a one-file
# change is a recompile plus a relink.
set -euo pipefail
cd /io
. scripts/_env.sh
"$PY" -m pip install -q --no-build-isolation --no-deps -e . 2>&1 | tail -5 || {
echo "editable install failed; falling back to a plain install" >&2
"$PY" -m pip install -q --no-build-isolation --no-deps --force-reinstall .
}
"$PY" -m pytest tests -q "$@"

39
scripts/stubgen.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Regenerate type stubs from the built extension.
#
# Must run where the OCCT shared libraries resolve (inside the builder image,
# pre-auditwheel), since stubgen imports the module to introspect it. Shipping
# these plus py.typed is what retires the backend's blanket
# `unresolved-import = "ignore"` ty suppression.
set -euo pipefail
cd /io
. scripts/_env.sh
SO=$(find "$CACHE/skbuild" -name '_OCP*.so' 2>/dev/null | head -1)
if [ -z "$SO" ]; then
echo "no built extension found; run the wheel build first" >&2
exit 1
fi
# Stage the package tree next to the freshly built extension so stubgen can
# import it without touching the repo copy.
STAGE=$(mktemp -d)
trap 'rm -rf "$STAGE"' EXIT
cp -r python/OCP python/n3xd_ocp "$STAGE/"
cp "$SO" "$STAGE/OCP/"
# -O <pkg> keeps each package's stubs inside its own directory: recursive
# stubgen names files after the submodule alone, so a shared output dir would
# flatten OCP.gp and n3xd_ocp.bintools into the same namespace.
(cd "$STAGE" && "$PY" -m nanobind.stubgen -m OCP -r -O OCP && \
"$PY" -m nanobind.stubgen -m n3xd_ocp -r -O n3xd_ocp)
find "$STAGE" -name '*.pyi' | while read -r f; do
rel="${f#"$STAGE"/}"
mkdir -p "python/$(dirname "$rel")"
cp "$f" "python/$rel"
done
echo "stubs written:"
find python -name '*.pyi' | sort

View File

@@ -0,0 +1,109 @@
#include "occt_exceptions.h"
#include "occt_module.h"
#include <Standard_ConstructionError.hxx>
#include <Standard_DimensionError.hxx>
#include <Standard_DimensionMismatch.hxx>
#include <Standard_DivideByZero.hxx>
#include <Standard_DomainError.hxx>
#include <Standard_Failure.hxx>
#include <Standard_ImmutableObject.hxx>
#include <Standard_NoSuchObject.hxx>
#include <Standard_NotImplemented.hxx>
#include <Standard_NullObject.hxx>
#include <Standard_NullValue.hxx>
#include <Standard_NumericError.hxx>
#include <Standard_OutOfRange.hxx>
#include <Standard_Overflow.hxx>
#include <Standard_ProgramError.hxx>
#include <Standard_RangeError.hxx>
#include <Standard_TypeMismatch.hxx>
#include <Standard_Underflow.hxx>
#include <StdFail_NotDone.hxx>
#include <StdFail_UndefinedDerivative.hxx>
#include <StdFail_UndefinedValue.hxx>
#include <cstring>
#include <string>
#include <unordered_map>
namespace {
/// OCCT class name -> Python exception type. Populated at import; read-only
/// afterwards, so it is fork-safe and needs no lock.
std::unordered_map<std::string, PyObject *> g_exc_types;
PyObject *g_base = nullptr; // OCP.Standard.Standard_Failure
PyObject *make_exc(nb::module_ &mod, const char *qualified, const char *name,
PyObject *base) {
PyObject *exc = PyErr_NewException(qualified, base, nullptr);
if (!exc)
throw nb::python_error();
mod.attr(name) = nb::borrow(exc);
g_exc_types.emplace(name, exc);
return exc;
}
} // namespace
void register_occt_exceptions(nb::module_ &root) {
nb::module_ std_mod = ocp_submodule(root, "Standard");
nb::module_ fail_mod = ocp_submodule(root, "StdFail");
// Standard_Failure derives RuntimeError: that single fact is what keeps
// the backend's `except RuntimeError` sites working unchanged.
g_base = make_exc(std_mod, "OCP.Standard.Standard_Failure",
"Standard_Failure", PyExc_RuntimeError);
auto sub = [&](nb::module_ &m, const char *qualified, const char *name) {
return make_exc(m, qualified, name, g_base);
};
#define OCP_EXC(modvar, modname, name) \
sub(modvar, "OCP." #modname "." #name, #name)
OCP_EXC(std_mod, Standard, Standard_DomainError);
OCP_EXC(std_mod, Standard, Standard_RangeError);
OCP_EXC(std_mod, Standard, Standard_OutOfRange);
OCP_EXC(std_mod, Standard, Standard_NoSuchObject);
OCP_EXC(std_mod, Standard, Standard_TypeMismatch);
OCP_EXC(std_mod, Standard, Standard_NullObject);
OCP_EXC(std_mod, Standard, Standard_NullValue);
OCP_EXC(std_mod, Standard, Standard_ConstructionError);
OCP_EXC(std_mod, Standard, Standard_DimensionError);
OCP_EXC(std_mod, Standard, Standard_DimensionMismatch);
OCP_EXC(std_mod, Standard, Standard_NumericError);
OCP_EXC(std_mod, Standard, Standard_DivideByZero);
OCP_EXC(std_mod, Standard, Standard_Overflow);
OCP_EXC(std_mod, Standard, Standard_Underflow);
OCP_EXC(std_mod, Standard, Standard_ProgramError);
OCP_EXC(std_mod, Standard, Standard_NotImplemented);
OCP_EXC(std_mod, Standard, Standard_ImmutableObject);
OCP_EXC(fail_mod, StdFail, StdFail_NotDone);
OCP_EXC(fail_mod, StdFail, StdFail_UndefinedDerivative);
OCP_EXC(fail_mod, StdFail, StdFail_UndefinedValue);
#undef OCP_EXC
nb::register_exception_translator(
[](const std::exception_ptr &p, void *) {
try {
std::rethrow_exception(p);
} catch (const Standard_Failure &e) {
// Dispatch on the dynamic OCCT type so the Python type name
// matches what the kernel actually threw; anything we did not
// bind falls back to the RuntimeError-derived base.
PyObject *type = g_base;
const char *name = e.DynamicType()->Name();
if (name) {
auto it = g_exc_types.find(name);
if (it != g_exc_types.end())
type = it->second;
}
const char *msg = e.GetMessageString();
std::string text = std::string(name ? name : "Standard_Failure");
if (msg && *msg)
text = std::string(msg);
PyErr_SetString(type, text.c_str());
}
},
nullptr);
}

View File

@@ -0,0 +1,20 @@
/*
OCCT Standard_Failure -> Python exception translation.
The backend's discipline is `IsDone()` gates (47 sites) plus `except
RuntimeError` (~10 sites); it never names an OCCT exception class. So the
hard requirement is that every kernel failure arrives as something deriving
RuntimeError. The concrete type names matter too, but for a subtler
reason: cad_pool's worker children marshal failures home as
f"{type(exc).__name__}: {exc}" strings, so the name is observable output.
*/
#pragma once
#include <nanobind/nanobind.h>
namespace nb = nanobind;
/// Create the OCP.Standard / OCP.StdFail exception classes and install the
/// translator. Call once, before any module that can raise.
void register_occt_exceptions(nb::module_ &root);

124
src/common/occt_handle.h Normal file
View File

@@ -0,0 +1,124 @@
/*
Type caster for opencascade::handle<T> — the ownership core of this binding.
OCCT transients carry their own intrusive, atomic reference count
(Standard_Transient), so a handle keeps an object alive entirely on the C++
side. That is what lets us release the GIL around kernel calls: OCCT may
copy handles on its own worker threads without ever touching Python.
The design mirrors nanobind's own stl/shared_ptr.h caster:
C++ -> Python the wrapper is a *non-owning* nanobind instance pointing
at the C++ object, plus one handle stored in the object's
keep-alive list. Python holds exactly one OCCT reference
per wrapper, released at deallocation. An existing
wrapper is reused (is_new == false), so `a is b` holds for
as long as a wrapper stays alive.
Python -> C++ a plain handle copy (one incref), balanced when the caster
dies after the call. Unlike shared_ptr we do *not* need
to keep the PyObject alive: the OCCT refcount, not the
Python instance, owns the object's memory.
That last sentence is only true if every wrapped transient was heap
allocated and is handle-owned. See occt_transient.h — transient
constructors are bound through OCP_TRANSIENT_NEW, never nb::init<>, so
Python never owns transient storage. from_python re-checks the invariant
rather than trusting it, because the failure mode is a double free.
*/
#pragma once
#include <nanobind/nanobind.h>
#include <Standard_Handle.hxx>
#include <Standard_Transient.hxx>
#include <type_traits>
NAMESPACE_BEGIN(NB_NAMESPACE)
NAMESPACE_BEGIN(detail)
// Marked NB_NOINLINE so the (identical) body is not duplicated into every
// instantiation of the caster below — there is one per bound transient class.
inline NB_NOINLINE void
occt_handle_keep_alive(opencascade::handle<Standard_Transient> &&h,
PyObject *o) noexcept {
keep_alive(o, new opencascade::handle<Standard_Transient>(std::move(h)),
[](void *p) noexcept {
delete (opencascade::handle<Standard_Transient> *) p;
});
}
template <typename T>
struct type_caster<
opencascade::handle<T>,
enable_if_t<std::is_base_of_v<Standard_Transient, std::decay_t<T>>>> {
static constexpr bool IsClass = true;
using Caster = make_caster<T>;
using Td = std::decay_t<T>;
NB_TYPE_CASTER(opencascade::handle<T>, Caster::Name)
static_assert(is_base_caster_v<Caster>,
"Conversion of opencascade::handle<T> requires that T is "
"bound through nanobind's regular class mechanism.");
bool from_python(handle src, uint8_t flags,
cleanup_list *cleanup) noexcept {
// A null handle is OCCT's absent value and maps to None in both
// directions; several APIs (BRep_Tool::Surface on a degenerate edge,
// for one) legitimately return one.
if (src.is_none()) {
value = Value();
return true;
}
flags &= ~((uint8_t) cast_flags::convert);
Caster caster;
if (!caster.from_python(src, flags, cleanup))
return false;
Td *ptr = caster.operator Td *();
// Invariant check, not defensive programming: a refcount of zero means
// this wrapper owns its storage (nb::init<>, or a by-value return),
// and taking a handle to it would hand OCCT the right to `delete` a
// nanobind instance's memory. Refuse instead — a TypeError beats a
// heap corruption, and it can only be reached by a binding bug.
if (ptr && ptr->GetRefCount() == 0) {
assert(!"OCCT transient is not handle-owned (see occt_transient.h)");
return false;
}
value = Value(ptr);
return true;
}
static handle from_cpp(const Value &v, rv_policy,
cleanup_list *cleanup) noexcept {
Td *ptr = v.get();
if (!ptr)
return none().release();
// Transients are polymorphic, so nb_type_put_p downcasts on the
// dynamic type: BRep_Tool::Surface returning a Geom_Surface handle to
// a plane arrives in Python as Geom_Plane when that class is bound.
bool is_new = false;
handle result = nb_type_put_p(&typeid(Td), &typeid(*ptr), (void *) ptr,
rv_policy::reference, cleanup, &is_new);
// Only a freshly created wrapper takes a reference. Attaching one per
// conversion would pile up redundant handles on a long-lived object
// that crosses the boundary many times.
if (result.is_valid() && is_new)
occt_handle_keep_alive(
opencascade::handle<Standard_Transient>(ptr), result.ptr());
return result;
}
};
NAMESPACE_END(detail)
NAMESPACE_END(NB_NAMESPACE)

View File

@@ -0,0 +1,28 @@
#include "occt_module.h"
#include <algorithm>
std::vector<std::string> &ocp_module_registry() {
static std::vector<std::string> registry;
return registry;
}
nb::module_ ocp_named_module(const char *full_name) {
// Borrowed reference, and already interned in sys.modules — which is what
// makes `import OCP.TopoDS` work without a .py shim per module. Note this
// does not require the parent package to exist yet.
PyObject *mod = PyImport_AddModule(full_name);
if (!mod)
throw nb::python_error();
return nb::borrow<nb::module_>(mod);
}
nb::module_ ocp_submodule(nb::module_ &parent, const char *name) {
nb::module_ m = ocp_named_module((std::string("OCP.") + name).c_str());
parent.attr(name) = m;
auto &reg = ocp_module_registry();
if (std::find(reg.begin(), reg.end(), name) == reg.end())
reg.emplace_back(name);
return m;
}

49
src/common/occt_module.h Normal file
View File

@@ -0,0 +1,49 @@
/*
Submodule and static-method helpers.
The wheel ships one extension, OCP/_OCP, which registers the OCP.*
submodules itself. They are created with PyImport_AddModule so they are
real entries in sys.modules: `import OCP.TopoDS`, `from OCP.TopoDS import
TopoDS_Shape` and cls.__module__ == "OCP.TopoDS" all work without a shim
module per name. (nanobind has no def_submodule.)
*/
#pragma once
#include <nanobind/nanobind.h>
// Included here, not per module, so no translation unit can accidentally bind
// a handle-returning API without the caster in scope: the symptom is a silent
// fallback that reports "unable to convert" only at call time.
#include "occt_handle.h"
#include <string>
#include <vector>
namespace nb = nanobind;
// Every module file wants "name"_a for keyword arguments.
using namespace nb::literals;
/// Names of every OCP.* submodule registered so far, in registration order.
/// OCP/__init__.py re-exports these so `OCP.TopoDS` resolves as an attribute
/// too, not only as an import.
std::vector<std::string> &ocp_module_registry();
/// Create (or fetch) a real module by its full dotted name. Idempotent, and
/// deliberately does not require the parent package to exist — n3xd_ocp's
/// submodules are registered this way so that the Python package's __init__
/// still runs whichever import happens first.
nb::module_ ocp_named_module(const char *full_name);
/// Create (or fetch) "OCP.<name>", attach it to `parent`, and record it for
/// OCP/__init__.py's re-export loop.
nb::module_ ocp_submodule(nb::module_ &parent, const char *name);
/// Bind an OCCT static method under the `_s` suffix.
///
/// Upstream OCP suffixes every static it exposes, and the app calls 176 of
/// them by that name (BRepGProp.SurfaceProperties_s, TopExp.MapShapes_s,
/// BinTools.Write_s, ...). The rule here is blanket rather than clash-driven,
/// so no static can accidentally ship without it.
#define OCP_DEF_S(cls, name, ...) (cls).def_static(name "_s", __VA_ARGS__)

View File

@@ -0,0 +1,35 @@
/*
Return-value and GIL policies.
Two rules live here, and both close a defect class the app currently works
around in Python:
1. Sub-shapes come back BY VALUE. A TopoDS_Shape is a small value holding
a handle to its TShape, so copying it is one incref and the copy owns
what it points at. Returning a reference into a container (an explorer's
Current(), a map's FindKey(), a BOP history list) instead lets the
wrapper outlive its owner — that is what segfaulted a process-global
face memo under upstream OCP, and why cad/topology/geom_memo.py is
scoped to a single rebuild by a ContextVar.
nanobind copies lvalue references by default; OCP_RETURN_COPY states it
explicitly so a reviewer can see the guarantee at the call site.
2. The GIL is released around calls that stay inside the kernel. The list
is deliberate rather than blanket: a call that can re-enter Python (the
BinTools stream bridge, notably) must keep it.
*/
#pragma once
#include <nanobind/nanobind.h>
namespace nb = nanobind;
/// Explicit "returns an owned copy" policy for shape-valued returns.
#define OCP_RETURN_COPY nb::rv_policy::copy
/// Release the GIL for the duration of a kernel call. Only for calls that
/// cannot re-enter the interpreter: Build/Perform, meshing, BRepCheck,
/// file-based readers and writers.
#define OCP_NOGIL nb::call_guard<nb::gil_scoped_release>()

41
src/common/occt_stream.h Normal file
View File

@@ -0,0 +1,41 @@
/*
Python file-like <-> std::stream adaptation for BinTools.
Upstream's signature takes an io.BytesIO, and the app always passes one
(cad_pool and derive.py serialise shapes through BytesIO). Rather than
bridge a streambuf that calls back into Python on every chunk, this slurps:
read the whole payload first, then hand the kernel a pure C++ stream.
Two things fall out of that. It is correct no matter how BinTools seeks
inside the stream, and the kernel half touches no Python at all — so the
GIL can be released around it, which a callback bridge could not do. The
cost is one extra copy of the BREP bytes, which n3xd_ocp.bintools avoids
entirely for the pool paths that care.
*/
#pragma once
#include <nanobind/nanobind.h>
#include <string>
namespace nb = nanobind;
/// Read a Python file-like object to end of stream.
inline std::string ocp_slurp(nb::handle stream) {
nb::object data = nb::getattr(stream, "read")();
char *buf = nullptr;
Py_ssize_t size = 0;
if (PyBytes_AsStringAndSize(data.ptr(), &buf, &size) != 0)
throw nb::python_error();
return std::string(buf, (size_t) size);
}
/// Write a byte payload to a Python file-like object.
inline void ocp_spit(nb::handle stream, const std::string &data) {
nb::object payload = nb::steal(
PyBytes_FromStringAndSize(data.data(), (Py_ssize_t) data.size()));
if (!payload.is_valid())
throw nb::python_error();
nb::getattr(stream, "write")(payload);
}

View File

@@ -0,0 +1,42 @@
/*
Binding helpers for OCCT transient (handle-managed) classes.
Rule: a transient class never gets nb::init<>. nanobind's normal
constructor placement-news the object into the Python instance's own
storage, which OCCT would later try to `delete` when the last handle goes
away. The app hits this on a real path — sketch_builder/edges.py builds a
Geom_BSplineCurve in Python and hands it to BRepBuilderAPI_MakeEdge, which
stores a handle to it that outlives the call.
So constructors are bound through nb::new_ returning a handle: the object
is heap allocated and reaches Python through the handle caster, owning its
own storage from birth. occt_handle.h's from_python check enforces this.
*/
#pragma once
#include <nanobind/nanobind.h>
#include <Standard_Handle.hxx>
#include <Standard_Transient.hxx>
#include <utility>
namespace nb = nanobind;
/// Declare a bound transient class. Bind its constructors with ocp_new<T,
/// Args...>() — nb::init<> on such a class is a bug (see above).
template <typename T, typename... Bases>
nb::class_<T, Bases...> ocp_transient_class(nb::handle scope, const char *name) {
return nb::class_<T, Bases...>(scope, name);
}
/// Heap-allocating constructor for a transient class:
///
/// cls.def(ocp_new<Poly_Triangulation, int, int, bool>(),
/// "nbNodes"_a, "nbTriangles"_a, "hasUVNodes"_a);
template <typename T, typename... Args> auto ocp_new() {
return nb::new_([](Args... args) {
return opencascade::handle<T>(new T(std::forward<Args>(args)...));
});
}

59
src/core.cpp Normal file
View File

@@ -0,0 +1,59 @@
/*
OCP._OCP — the single extension module.
One .so registers every OCP.* submodule rather than shipping one extension
per module: the whole surface traffics in TopoDS_Shape, gp_* and handles,
so sharing types in-process is free here and would otherwise depend on
nanobind's cross-extension registry; registration order stays an explicit
sequence below instead of an import-order puzzle; and cad_pool's forkserver
warms exactly one dlopen. Per-module .cpp files keep incremental compiles
cheap — only the final link is shared.
*/
#include <nanobind/nanobind.h>
#include <nanobind/stl/string.h>
#include <nanobind/stl/vector.h>
#include "common/occt_exceptions.h"
#include "common/occt_module.h"
#include <Standard_Version.hxx>
namespace nb = nanobind;
void register_Standard(nb::module_ &);
void register_gp(nb::module_ &);
void register_TopAbs(nb::module_ &);
void register_TopLoc(nb::module_ &);
void register_TopoDS(nb::module_ &);
void register_TopTools(nb::module_ &);
void register_TopExp(nb::module_ &);
void register_Poly(nb::module_ &);
void register_BRep(nb::module_ &);
void register_BinTools(nb::module_ &);
void register_ext(nb::module_ &);
NB_MODULE(_OCP, m) {
m.doc() = "nanobind bindings for the OpenCASCADE geometry kernel";
// Exceptions first: they create OCP.Standard and OCP.StdFail, and any
// module registered afterwards may raise through the translator.
register_occt_exceptions(m);
register_Standard(m);
register_gp(m);
register_TopAbs(m);
register_TopLoc(m);
register_TopoDS(m);
register_TopTools(m);
register_TopExp(m);
register_Poly(m);
register_BRep(m);
register_BinTools(m);
register_ext(m);
m.attr("__all_modules__") = ocp_module_registry();
m.attr("__occt_version__") = OCC_VERSION_COMPLETE;
m.attr("__version__") = N3XD_OCP_VERSION;
}

53
src/ext/ext_bintools.cpp Normal file
View File

@@ -0,0 +1,53 @@
/*
n3xd_ocp.bintools — shape <-> bytes without the file-object detour.
The pools move every shape as BinTools bytes through a BytesIO purely
because that is the shape of upstream's API. Here the payload is a bytes
object directly: one buffer instead of a BytesIO plus its internal copy,
and the kernel half runs with the GIL released.
Byte-for-byte identical to OCP.BinTools output — same BinTools::Write, same
format version — so it is interchangeable with it, including for the
content-addressed derive payloads.
*/
#include "../common/occt_module.h"
#include <nanobind/stl/string.h>
#include <BinTools.hxx>
#include <TopoDS_Shape.hxx>
#include <sstream>
void register_ext_bintools() {
nb::module_ m = ocp_named_module("n3xd_ocp.bintools");
m.def(
"write_bytes",
[](const TopoDS_Shape &shape) {
std::ostringstream out;
{
nb::gil_scoped_release nogil;
BinTools::Write(shape, out);
}
std::string data = out.str();
return nb::bytes(data.data(), data.size());
},
"shape"_a,
"Serialise a shape to BREP bytes (identical to OCP.BinTools.Write_s).");
m.def(
"read_bytes",
[](nb::bytes data) {
std::string buf(data.c_str(), data.size());
TopoDS_Shape shape;
{
nb::gil_scoped_release nogil;
std::istringstream in(buf);
BinTools::Read(shape, in);
}
return shape;
},
"data"_a, "Deserialise BREP bytes into a shape.");
}

25
src/ext/ext_core.cpp Normal file
View File

@@ -0,0 +1,25 @@
/*
n3xd_ocp — the extension surface, kept out of the OCP namespace on purpose.
OCP.* stays a drop-in replacement for cadquery-ocp-novtk, symbol for symbol,
so parity testing means something. Anything that goes *beyond* upstream —
bulk array extraction, batched measurement, GIL-free serialisation — lives
here instead, in the same wheel. The backend adopts these after the
cutover, one call site at a time.
Only the leaf modules are registered here. Creating the `n3xd_ocp` parent
from C++ would put a bare module in sys.modules, and a later `import
n3xd_ocp` would then find it and never run the package's __init__.py.
*/
#include "../common/occt_module.h"
namespace nb = nanobind;
void register_ext_bintools();
void register_ext_debug();
void register_ext(nb::module_ &) {
register_ext_bintools();
register_ext_debug();
}

78
src/ext/ext_debug.cpp Normal file
View File

@@ -0,0 +1,78 @@
/*
n3xd_ocp._debug — introspection the handle-model tests need.
Not part of the supported surface; it exists so tests can assert ownership
invariants (OCCT refcounts, wrapper identity) that are otherwise invisible
from Python.
*/
#include "../common/occt_handle.h"
#include "../common/occt_module.h"
#include <Poly_Triangulation.hxx>
#include <Standard_Transient.hxx>
#include <vector>
namespace {
/// Holds handles alive on the C++ side so a test can drop every Python
/// reference and check the object survived.
std::vector<opencascade::handle<Standard_Transient>> &cpp_holds() {
static std::vector<opencascade::handle<Standard_Transient>> held;
return held;
}
} // namespace
void register_ext_debug() {
nb::module_ m = ocp_named_module("n3xd_ocp._debug");
m.def(
"refcount",
[](const opencascade::handle<Standard_Transient> &h) {
// The caster's own handle is alive for the duration of the call,
// so subtract it to report what the caller actually holds.
return h.IsNull() ? 0 : h->GetRefCount() - 1;
},
nb::arg("obj").none(),
"OCCT reference count excluding this call's own handle.");
m.def(
"roundtrip",
[](const opencascade::handle<Standard_Transient> &h) { return h; },
nb::arg("obj").none(),
"Send a transient through C++ and back — must return the same wrapper.");
m.def(
"hold",
[](const opencascade::handle<Standard_Transient> &h) {
cpp_holds().push_back(h);
return (int) cpp_holds().size() - 1;
},
"obj"_a, "Keep a C++-side reference; returns its slot index.");
m.def(
"release_held", []() { cpp_holds().clear(); },
"Drop every C++-side reference taken by hold().");
m.def(
"held_is_alive",
[](int slot) {
auto &held = cpp_holds();
return slot >= 0 && slot < (int) held.size() &&
!held[(size_t) slot].IsNull();
},
"slot"_a);
m.def(
"held_nb_nodes",
[](int slot) {
auto &held = cpp_holds();
opencascade::handle<Poly_Triangulation> tri =
opencascade::handle<Poly_Triangulation>::DownCast(
held[(size_t) slot]);
return tri.IsNull() ? -1 : tri->NbNodes();
},
"slot"_a, "Touch a held object's memory — catches a premature free.");
}

73
src/modules/mod_BRep.cpp Normal file
View File

@@ -0,0 +1,73 @@
/*
OCP.BRep — builder plus the Inc 0 half of BRep_Tool.
BRep_Tool::Surface / Curve return Geom handles and land with Inc 1, where
the Geom classes arrive; Triangulation is here because it is what the
tessellation path reads and what the handle spike exercises.
*/
#include "../common/occt_module.h"
#include "../common/occt_policies.h"
#include <BRep_Builder.hxx>
#include <BRep_Tool.hxx>
#include <Poly_Triangulation.hxx>
#include <TopLoc_Location.hxx>
#include <TopoDS_Compound.hxx>
#include <TopoDS_Edge.hxx>
#include <TopoDS_Face.hxx>
#include <TopoDS_Shell.hxx>
#include <TopoDS_Solid.hxx>
#include <TopoDS_Vertex.hxx>
#include <TopoDS_Wire.hxx>
void register_BRep(nb::module_ &root) {
nb::module_ m = ocp_submodule(root, "BRep");
nb::class_<BRep_Builder>(m, "BRep_Builder")
.def(nb::init<>())
.def("MakeCompound", &BRep_Builder::MakeCompound, "C"_a)
.def("MakeShell", &BRep_Builder::MakeShell, "S"_a)
.def("MakeSolid", &BRep_Builder::MakeSolid, "S"_a)
.def("MakeWire", &BRep_Builder::MakeWire, "W"_a)
.def("Add",
[](const BRep_Builder &self, TopoDS_Shape &S,
const TopoDS_Shape &C) { self.Add(S, C); },
"S"_a, "C"_a)
.def("Remove",
[](const BRep_Builder &self, TopoDS_Shape &S,
const TopoDS_Shape &C) { self.Remove(S, C); },
"S"_a, "C"_a);
nb::class_<BRep_Tool> cls(m, "BRep_Tool");
// The location is an in/out parameter, matching upstream and the five call
// sites in the app (`BRep_Tool.Triangulation_s(face, loc)`): nanobind hands
// the lambda a reference into the caller's own instance, so the assignment
// is visible in Python. A face without a mesh yields a null handle, which
// the caster maps to None.
OCP_DEF_S(
cls, "Triangulation",
[](const TopoDS_Face &F, TopLoc_Location &L) {
return BRep_Tool::Triangulation(F, L);
},
"F"_a, "L"_a);
OCP_DEF_S(
cls, "Pnt", [](const TopoDS_Vertex &V) { return BRep_Tool::Pnt(V); },
"V"_a);
OCP_DEF_S(
cls, "Degenerated",
[](const TopoDS_Edge &E) { return BRep_Tool::Degenerated(E); }, "E"_a);
OCP_DEF_S(
cls, "Tolerance",
[](const TopoDS_Face &F) { return BRep_Tool::Tolerance(F); }, "F"_a);
OCP_DEF_S(
cls, "Tolerance",
[](const TopoDS_Edge &E) { return BRep_Tool::Tolerance(E); }, "E"_a);
OCP_DEF_S(
cls, "Tolerance",
[](const TopoDS_Vertex &V) { return BRep_Tool::Tolerance(V); }, "V"_a);
OCP_DEF_S(
cls, "IsClosed",
[](const TopoDS_Shape &S) { return BRep_Tool::IsClosed(S); }, "S"_a);
}

View File

@@ -0,0 +1,51 @@
/*
OCP.BinTools — the IPC-critical path.
Every subprocess pool serialises shapes through here, and derive.py content-
addresses the result (payloads/derived/brep/<sha256>.brep) with the digest
stored in the document. So byte-stability is a correctness requirement,
not merely a round-trip one, and it is what the Inc 0 gate measures.
The GIL is released around the kernel half. That is possible because the
Python file object is drained (or filled) on either side of the call rather
than bridged through a streambuf that would call back into the interpreter
mid-serialisation — see occt_stream.h.
*/
#include "../common/occt_module.h"
#include "../common/occt_stream.h"
#include <BinTools.hxx>
#include <TopoDS_Shape.hxx>
#include <sstream>
void register_BinTools(nb::module_ &root) {
nb::module_ m = ocp_submodule(root, "BinTools");
nb::class_<BinTools> cls(m, "BinTools");
// Matches upstream overload 1: writes with triangulation, at
// BinTools_FormatVersion_CURRENT.
OCP_DEF_S(
cls, "Write",
[](const TopoDS_Shape &theShape, nb::object theStream) {
std::ostringstream out;
{
nb::gil_scoped_release nogil;
BinTools::Write(theShape, out);
}
ocp_spit(theStream, out.str());
},
"theShape"_a, "theStream"_a);
OCP_DEF_S(
cls, "Read",
[](TopoDS_Shape &theShape, nb::object theStream) {
std::string data = ocp_slurp(theStream);
std::istringstream in(data);
nb::gil_scoped_release nogil;
BinTools::Read(theShape, in);
},
"theShape"_a, "theStream"_a);
}

56
src/modules/mod_Poly.cpp Normal file
View File

@@ -0,0 +1,56 @@
/*
OCP.Poly — also the handle-model spike vehicle.
Poly_Triangulation is a transient (handle-managed) class that the app both
receives from the kernel (BRep_Tool::Triangulation) and, in the wider
surface, constructs. It is therefore the natural place to prove the
ownership rules in occt_handle.h before the rest of the surface depends on
them. Constructors go through ocp_new, never nb::init<>.
*/
#include "../common/occt_module.h"
#include "../common/occt_policies.h"
#include "../common/occt_transient.h"
#include <Poly_Triangulation.hxx>
void register_Poly(nb::module_ &root) {
nb::module_ m = ocp_submodule(root, "Poly");
nb::class_<Poly_Triangle>(m, "Poly_Triangle")
.def(nb::init<>())
.def(nb::init<Standard_Integer, Standard_Integer, Standard_Integer>(),
"theN1"_a, "theN2"_a, "theN3"_a)
.def("Value", &Poly_Triangle::Value, "theIndex"_a)
.def("Get", [](const Poly_Triangle &t) {
Standard_Integer n1 = 0, n2 = 0, n3 = 0;
t.Get(n1, n2, n3);
return nb::make_tuple(n1, n2, n3);
});
nb::class_<Poly_Triangulation, Standard_Transient>(m, "Poly_Triangulation")
.def(ocp_new<Poly_Triangulation>())
.def(ocp_new<Poly_Triangulation, Standard_Integer, Standard_Integer,
Standard_Boolean, Standard_Boolean>(),
"theNbNodes"_a, "theNbTriangles"_a, "theHasUVNodes"_a,
"theHasNormals"_a = Standard_False)
.def("NbNodes", &Poly_Triangulation::NbNodes)
.def("NbTriangles", &Poly_Triangulation::NbTriangles)
.def("HasUVNodes", &Poly_Triangulation::HasUVNodes)
.def("HasNormals", &Poly_Triangulation::HasNormals)
.def("Node", &Poly_Triangulation::Node, "theIndex"_a, OCP_RETURN_COPY)
.def("SetNode", &Poly_Triangulation::SetNode, "theIndex"_a,
"thePnt"_a)
.def("Triangle", &Poly_Triangulation::Triangle, "theIndex"_a,
OCP_RETURN_COPY)
.def("SetTriangle", &Poly_Triangulation::SetTriangle, "theIndex"_a,
"theTriangle"_a)
.def("Normal",
nb::overload_cast<Standard_Integer>(&Poly_Triangulation::Normal,
nb::const_),
"theIndex"_a, OCP_RETURN_COPY)
.def("UVNode", &Poly_Triangulation::UVNode, "theIndex"_a,
OCP_RETURN_COPY)
.def("Deflection",
nb::overload_cast<>(&Poly_Triangulation::Deflection, nb::const_));
}

View File

@@ -0,0 +1,25 @@
#include "../common/occt_module.h"
#include <Standard_Transient.hxx>
#include <Standard_Type.hxx>
#include <Standard_Version.hxx>
void register_Standard(nb::module_ &root) {
nb::module_ m = ocp_submodule(root, "Standard");
// Every handle-managed class derives from this, and nanobind needs it
// registered before it can be named as a base. Deliberately not
// constructible from Python: transients are created through ocp_new (see
// occt_transient.h), never by placement-new into instance storage.
nb::class_<Standard_Transient>(m, "Standard_Transient")
.def("GetRefCount", &Standard_Transient::GetRefCount)
.def("IsInstance",
[](const Standard_Transient &self, const char *name) {
return self.IsKind(name);
})
.def("DynamicType", [](const Standard_Transient &self) {
return std::string(self.DynamicType()->Name());
});
m.attr("OCC_VERSION_COMPLETE") = OCC_VERSION_COMPLETE;
}

View File

@@ -0,0 +1,44 @@
#include "../common/occt_module.h"
#include <TopAbs.hxx>
#include <TopAbs_Orientation.hxx>
#include <TopAbs_ShapeEnum.hxx>
#include <TopAbs_State.hxx>
void register_TopAbs(nb::module_ &root) {
nb::module_ m = ocp_submodule(root, "TopAbs");
// is_arithmetic + export_values reproduce the pybind11 enum behaviour the
// app relies on: members at module scope (`from OCP.TopAbs import
// TopAbs_FACE`) and int comparison.
nb::enum_<TopAbs_ShapeEnum>(m, "TopAbs_ShapeEnum", nb::is_arithmetic())
.value("TopAbs_COMPOUND", TopAbs_COMPOUND)
.value("TopAbs_COMPSOLID", TopAbs_COMPSOLID)
.value("TopAbs_SOLID", TopAbs_SOLID)
.value("TopAbs_SHELL", TopAbs_SHELL)
.value("TopAbs_FACE", TopAbs_FACE)
.value("TopAbs_WIRE", TopAbs_WIRE)
.value("TopAbs_EDGE", TopAbs_EDGE)
.value("TopAbs_VERTEX", TopAbs_VERTEX)
.value("TopAbs_SHAPE", TopAbs_SHAPE)
.export_values();
nb::enum_<TopAbs_Orientation>(m, "TopAbs_Orientation", nb::is_arithmetic())
.value("TopAbs_FORWARD", TopAbs_FORWARD)
.value("TopAbs_REVERSED", TopAbs_REVERSED)
.value("TopAbs_INTERNAL", TopAbs_INTERNAL)
.value("TopAbs_EXTERNAL", TopAbs_EXTERNAL)
.export_values();
nb::enum_<TopAbs_State>(m, "TopAbs_State", nb::is_arithmetic())
.value("TopAbs_IN", TopAbs_IN)
.value("TopAbs_OUT", TopAbs_OUT)
.value("TopAbs_ON", TopAbs_ON)
.value("TopAbs_UNKNOWN", TopAbs_UNKNOWN)
.export_values();
nb::class_<TopAbs> cls(m, "TopAbs");
OCP_DEF_S(cls, "Compose", &TopAbs::Compose, "Or1"_a, "Or2"_a);
OCP_DEF_S(cls, "Reverse", &TopAbs::Reverse, "Or"_a);
OCP_DEF_S(cls, "Complement", &TopAbs::Complement, "Or"_a);
}

View File

@@ -0,0 +1,48 @@
#include "../common/occt_module.h"
#include "../common/occt_policies.h"
#include <TopExp.hxx>
#include <TopExp_Explorer.hxx>
#include <TopTools_IndexedDataMapOfShapeListOfShape.hxx>
#include <TopTools_IndexedMapOfShape.hxx>
void register_TopExp(nb::module_ &root) {
nb::module_ m = ocp_submodule(root, "TopExp");
nb::class_<TopExp_Explorer>(m, "TopExp_Explorer")
.def(nb::init<>())
.def(nb::init<const TopoDS_Shape &, TopAbs_ShapeEnum,
TopAbs_ShapeEnum>(),
"S"_a, "ToFind"_a, "ToAvoid"_a = TopAbs_SHAPE)
.def("Init", &TopExp_Explorer::Init, "S"_a, "ToFind"_a,
"ToAvoid"_a = TopAbs_SHAPE)
.def("More", &TopExp_Explorer::More)
.def("Next", &TopExp_Explorer::Next)
.def("Current", &TopExp_Explorer::Current, OCP_RETURN_COPY)
.def("Value", &TopExp_Explorer::Value, OCP_RETURN_COPY)
.def("ReInit", &TopExp_Explorer::ReInit)
.def("Depth", &TopExp_Explorer::Depth)
.def("Clear", &TopExp_Explorer::Clear);
nb::class_<TopExp> cls(m, "TopExp");
// MapShapes is the single most-called static in the backend (54 sites).
OCP_DEF_S(
cls, "MapShapes",
[](const TopoDS_Shape &S, TopAbs_ShapeEnum T,
TopTools_IndexedMapOfShape &M) { TopExp::MapShapes(S, T, M); },
"S"_a, "T"_a, "M"_a);
OCP_DEF_S(
cls, "MapShapes",
[](const TopoDS_Shape &S, TopTools_IndexedMapOfShape &M) {
TopExp::MapShapes(S, M);
},
"S"_a, "M"_a);
OCP_DEF_S(
cls, "MapShapesAndAncestors",
[](const TopoDS_Shape &S, TopAbs_ShapeEnum TS, TopAbs_ShapeEnum TA,
TopTools_IndexedDataMapOfShapeListOfShape &M) {
TopExp::MapShapesAndAncestors(S, TS, TA, M);
},
"S"_a, "TS"_a, "TA"_a, "M"_a);
}

View File

@@ -0,0 +1,24 @@
#include "../common/occt_module.h"
#include "../common/occt_policies.h"
#include <TopLoc_Location.hxx>
void register_TopLoc(nb::module_ &root) {
nb::module_ m = ocp_submodule(root, "TopLoc");
nb::class_<TopLoc_Location>(m, "TopLoc_Location")
.def(nb::init<>())
.def(nb::init<const gp_Trsf &>(), "T"_a)
.def("IsIdentity", &TopLoc_Location::IsIdentity)
.def("Identity", &TopLoc_Location::Identity)
.def("Transformation", &TopLoc_Location::Transformation,
OCP_RETURN_COPY)
.def("Inverted", &TopLoc_Location::Inverted, OCP_RETURN_COPY)
.def("Multiplied", &TopLoc_Location::Multiplied, "Other"_a,
OCP_RETURN_COPY)
.def("Divided", &TopLoc_Location::Divided, "Other"_a, OCP_RETURN_COPY)
.def("Predivided", &TopLoc_Location::Predivided, "Other"_a,
OCP_RETURN_COPY)
.def("IsEqual", &TopLoc_Location::IsEqual, "Other"_a)
.def("IsDifferent", &TopLoc_Location::IsDifferent, "Other"_a);
}

View File

@@ -0,0 +1,100 @@
/*
OCP.TopTools — the shape containers the topology code lives in.
Every accessor returns a shape by value (OCP_RETURN_COPY): a wrapper handed
out by FindKey must not become a dangling view when the map is cleared or
goes out of scope. build_entity_map_delta tests membership through
IndexedMapOfShape::Contains (IsSame semantics), which is why Contains and
FindIndex are bound rather than left to a Python-side scan.
*/
#include "../common/occt_module.h"
#include "../common/occt_policies.h"
#include <nanobind/make_iterator.h>
#include <TopTools_IndexedDataMapOfShapeListOfShape.hxx>
#include <TopTools_IndexedMapOfShape.hxx>
#include <TopTools_ListOfShape.hxx>
void register_TopTools(nb::module_ &root) {
nb::module_ m = ocp_submodule(root, "TopTools");
nb::class_<TopTools_ListOfShape>(m, "TopTools_ListOfShape")
.def(nb::init<>())
.def("Append",
[](TopTools_ListOfShape &self, const TopoDS_Shape &s) {
self.Append(s);
},
"theItem"_a)
.def("Prepend",
[](TopTools_ListOfShape &self, const TopoDS_Shape &s) {
self.Prepend(s);
},
"theItem"_a)
.def("Extent", &TopTools_ListOfShape::Extent)
.def("Size", &TopTools_ListOfShape::Size)
.def("IsEmpty", &TopTools_ListOfShape::IsEmpty)
.def("Clear", [](TopTools_ListOfShape &self) { self.Clear(); })
.def("First",
nb::overload_cast<>(&TopTools_ListOfShape::First, nb::const_),
OCP_RETURN_COPY)
.def("Last",
nb::overload_cast<>(&TopTools_ListOfShape::Last, nb::const_),
OCP_RETURN_COPY)
.def("__len__", &TopTools_ListOfShape::Size)
.def(
"__iter__",
[](const TopTools_ListOfShape &self) {
return nb::make_iterator<nb::rv_policy::copy>(
nb::type<TopTools_ListOfShape>(), "ListOfShapeIterator",
self.begin(), self.end());
},
nb::keep_alive<0, 1>());
nb::class_<TopTools_IndexedMapOfShape>(m, "TopTools_IndexedMapOfShape")
.def(nb::init<>())
.def("Add",
[](TopTools_IndexedMapOfShape &self, const TopoDS_Shape &s) {
return self.Add(s);
},
"theKey"_a)
.def("Contains", &TopTools_IndexedMapOfShape::Contains, "theKey"_a)
.def("FindKey", &TopTools_IndexedMapOfShape::FindKey, "theIndex"_a,
OCP_RETURN_COPY)
.def("FindIndex", &TopTools_IndexedMapOfShape::FindIndex, "theKey"_a)
.def("Extent", &TopTools_IndexedMapOfShape::Extent)
.def("Size", &TopTools_IndexedMapOfShape::Size)
.def("IsEmpty", &TopTools_IndexedMapOfShape::IsEmpty)
.def("Clear",
[](TopTools_IndexedMapOfShape &self) { self.Clear(Standard_True); })
.def("RemoveLast", &TopTools_IndexedMapOfShape::RemoveLast)
.def("__len__", &TopTools_IndexedMapOfShape::Size);
nb::class_<TopTools_IndexedDataMapOfShapeListOfShape>(
m, "TopTools_IndexedDataMapOfShapeListOfShape")
.def(nb::init<>())
.def("Contains", &TopTools_IndexedDataMapOfShapeListOfShape::Contains,
"theKey"_a)
.def("FindKey", &TopTools_IndexedDataMapOfShapeListOfShape::FindKey,
"theIndex"_a, OCP_RETURN_COPY)
.def("FindIndex", &TopTools_IndexedDataMapOfShapeListOfShape::FindIndex,
"theKey"_a)
.def("FindFromKey",
nb::overload_cast<const TopoDS_Shape &>(
&TopTools_IndexedDataMapOfShapeListOfShape::FindFromKey,
nb::const_),
"theKey"_a, OCP_RETURN_COPY)
.def("FindFromIndex",
nb::overload_cast<Standard_Integer>(
&TopTools_IndexedDataMapOfShapeListOfShape::FindFromIndex,
nb::const_),
"theIndex"_a, OCP_RETURN_COPY)
.def("Extent", &TopTools_IndexedDataMapOfShapeListOfShape::Extent)
.def("Size", &TopTools_IndexedDataMapOfShapeListOfShape::Size)
.def("Clear",
[](TopTools_IndexedDataMapOfShapeListOfShape &self) {
self.Clear(Standard_True);
})
.def("__len__", &TopTools_IndexedDataMapOfShapeListOfShape::Size);
}

114
src/modules/mod_TopoDS.cpp Normal file
View File

@@ -0,0 +1,114 @@
/*
OCP.TopoDS — shapes are value types, and that is the point.
A TopoDS_Shape is a small value (a handle to its TShape, a location, an
orientation), so every shape crossing into Python is an owned copy. A
wrapper can therefore never alias storage owned by an explorer, a map or a
BOP history list — which is the lifetime class that segfaulted a
process-global face memo under upstream OCP.
Fidelity note on __hash__ / __eq__, verified against the stock wheel:
upstream binds __hash__ (TShape + Location) and leaves __eq__ at Python's
default identity comparison. That pairing looks odd — two re-extracted
copies of one face hash equal but compare unequal — and it is exactly what
cad/topology/geom_memo.py is built around: it buckets on hash(face) and
disambiguates with IsSame, because == cannot be trusted. Binding __eq__ to
IsEqual here would silently change that memo's behaviour, so we match
upstream rather than improve on it.
*/
#include "../common/occt_module.h"
#include "../common/occt_policies.h"
#include <TopoDS.hxx>
#include <TopoDS_CompSolid.hxx>
#include <TopoDS_Compound.hxx>
#include <TopoDS_Edge.hxx>
#include <TopoDS_Face.hxx>
#include <TopoDS_Iterator.hxx>
#include <TopoDS_Shape.hxx>
#include <TopoDS_Shell.hxx>
#include <TopoDS_Solid.hxx>
#include <TopoDS_Vertex.hxx>
#include <TopoDS_Wire.hxx>
#include <functional>
namespace {
/// Stands in for the TopoDS namespace so its statics can hang off a Python
/// class of that name — see the comment at the downcast block below.
struct TopoDSStatics {};
} // namespace
void register_TopoDS(nb::module_ &root) {
nb::module_ m = ocp_submodule(root, "TopoDS");
nb::class_<TopoDS_Shape>(m, "TopoDS_Shape")
.def(nb::init<>())
.def("IsNull", &TopoDS_Shape::IsNull)
.def("Nullify", &TopoDS_Shape::Nullify)
.def("ShapeType", &TopoDS_Shape::ShapeType)
.def("Orientation",
nb::overload_cast<>(&TopoDS_Shape::Orientation, nb::const_))
.def("Location",
nb::overload_cast<>(&TopoDS_Shape::Location, nb::const_),
OCP_RETURN_COPY)
.def("Closed", nb::overload_cast<>(&TopoDS_Shape::Closed, nb::const_))
.def("Reverse", &TopoDS_Shape::Reverse)
.def("Reversed", &TopoDS_Shape::Reversed, OCP_RETURN_COPY)
.def("Moved", &TopoDS_Shape::Moved, "position"_a,
"raiseExc"_a = Standard_False, OCP_RETURN_COPY)
.def("Located", &TopoDS_Shape::Located, "loc"_a,
"raiseExc"_a = Standard_False, OCP_RETURN_COPY)
.def("IsSame", &TopoDS_Shape::IsSame, "other"_a)
.def("IsEqual", &TopoDS_Shape::IsEqual, "other"_a)
.def("IsPartner", &TopoDS_Shape::IsPartner, "other"_a)
.def("__hash__", [](const TopoDS_Shape &s) {
return std::hash<TopoDS_Shape>{}(s);
});
#define OCP_SHAPE_SUBCLASS(Type) \
nb::class_<Type, TopoDS_Shape>(m, #Type).def(nb::init<>())
OCP_SHAPE_SUBCLASS(TopoDS_Vertex);
OCP_SHAPE_SUBCLASS(TopoDS_Edge);
OCP_SHAPE_SUBCLASS(TopoDS_Wire);
OCP_SHAPE_SUBCLASS(TopoDS_Face);
OCP_SHAPE_SUBCLASS(TopoDS_Shell);
OCP_SHAPE_SUBCLASS(TopoDS_Solid);
OCP_SHAPE_SUBCLASS(TopoDS_CompSolid);
OCP_SHAPE_SUBCLASS(TopoDS_Compound);
#undef OCP_SHAPE_SUBCLASS
nb::class_<TopoDS_Iterator>(m, "TopoDS_Iterator")
.def(nb::init<>())
.def(nb::init<const TopoDS_Shape &, Standard_Boolean,
Standard_Boolean>(),
"S"_a, "cumOri"_a = Standard_True, "cumLoc"_a = Standard_True)
.def("More", &TopoDS_Iterator::More)
.def("Next", &TopoDS_Iterator::Next)
.def("Value", &TopoDS_Iterator::Value, OCP_RETURN_COPY);
// Checked downcasts. These raise Standard_TypeMismatch on a kind
// mismatch, which the translator turns into a RuntimeError subclass.
//
// OCCT 7.9 turned TopoDS from a class into a namespace, but upstream OCP
// still presents it as a class carrying the _s statics, and the app calls
// TopoDS.Face_s(...). An empty carrier type reproduces that surface.
nb::class_<TopoDSStatics> cls(m, "TopoDS");
#define OCP_DOWNCAST(Name) \
OCP_DEF_S( \
cls, #Name, \
[](const TopoDS_Shape &s) { return TopoDS::Name(s); }, "S"_a, \
OCP_RETURN_COPY)
OCP_DOWNCAST(Vertex);
OCP_DOWNCAST(Edge);
OCP_DOWNCAST(Wire);
OCP_DOWNCAST(Face);
OCP_DOWNCAST(Shell);
OCP_DOWNCAST(Solid);
OCP_DOWNCAST(CompSolid);
OCP_DOWNCAST(Compound);
#undef OCP_DOWNCAST
}

138
src/modules/mod_gp.cpp Normal file
View File

@@ -0,0 +1,138 @@
/*
OCP.gp — the Inc 0 subset (points, vectors, directions, axes, transforms).
The rest of gp (gp_Ax2/Ax3/Pln/Circ/Lin/Pnt2d/Dir2d/Quaternion, which the
app also uses) lands with Inc 1, where the curve and surface classes that
consume them arrive.
*/
#include "../common/occt_module.h"
#include "../common/occt_policies.h"
#include <gp_Ax1.hxx>
#include <gp_Dir.hxx>
#include <gp_Pnt.hxx>
#include <gp_Trsf.hxx>
#include <gp_Vec.hxx>
#include <gp_XYZ.hxx>
void register_gp(nb::module_ &root) {
nb::module_ m = ocp_submodule(root, "gp");
nb::enum_<gp_TrsfForm>(m, "gp_TrsfForm", nb::is_arithmetic())
.value("gp_Identity", gp_Identity)
.value("gp_Rotation", gp_Rotation)
.value("gp_Translation", gp_Translation)
.value("gp_PntMirror", gp_PntMirror)
.value("gp_Ax1Mirror", gp_Ax1Mirror)
.value("gp_Ax2Mirror", gp_Ax2Mirror)
.value("gp_Scale", gp_Scale)
.value("gp_CompoundTrsf", gp_CompoundTrsf)
.value("gp_Other", gp_Other)
.export_values();
nb::class_<gp_XYZ>(m, "gp_XYZ")
.def(nb::init<>())
.def(nb::init<Standard_Real, Standard_Real, Standard_Real>(), "X"_a,
"Y"_a, "Z"_a)
.def("X", &gp_XYZ::X)
.def("Y", &gp_XYZ::Y)
.def("Z", &gp_XYZ::Z);
nb::class_<gp_Pnt>(m, "gp_Pnt")
.def(nb::init<>())
.def(nb::init<Standard_Real, Standard_Real, Standard_Real>(), "Xp"_a,
"Yp"_a, "Zp"_a)
.def(nb::init<const gp_XYZ &>(), "Coord"_a)
.def("X", &gp_Pnt::X)
.def("Y", &gp_Pnt::Y)
.def("Z", &gp_Pnt::Z)
.def("SetX", &gp_Pnt::SetX, "X"_a)
.def("SetY", &gp_Pnt::SetY, "Y"_a)
.def("SetZ", &gp_Pnt::SetZ, "Z"_a)
.def("Coord", nb::overload_cast<>(&gp_Pnt::Coord, nb::const_),
OCP_RETURN_COPY)
.def("Distance", &gp_Pnt::Distance, "Other"_a)
.def("SquareDistance", &gp_Pnt::SquareDistance, "Other"_a)
.def("IsEqual", &gp_Pnt::IsEqual, "Other"_a, "LinearTolerance"_a)
.def("Transform", &gp_Pnt::Transform, "T"_a)
.def("Transformed", &gp_Pnt::Transformed, "T"_a, OCP_RETURN_COPY)
.def("Translate",
nb::overload_cast<const gp_Vec &>(&gp_Pnt::Translate), "V"_a)
.def("Translated",
nb::overload_cast<const gp_Vec &>(&gp_Pnt::Translated, nb::const_),
"V"_a, OCP_RETURN_COPY);
nb::class_<gp_Vec>(m, "gp_Vec")
.def(nb::init<>())
.def(nb::init<Standard_Real, Standard_Real, Standard_Real>(), "Xv"_a,
"Yv"_a, "Zv"_a)
.def(nb::init<const gp_Dir &>(), "V"_a)
.def(nb::init<const gp_Pnt &, const gp_Pnt &>(), "P1"_a, "P2"_a)
.def("X", &gp_Vec::X)
.def("Y", &gp_Vec::Y)
.def("Z", &gp_Vec::Z)
.def("Magnitude", &gp_Vec::Magnitude)
.def("SquareMagnitude", &gp_Vec::SquareMagnitude)
.def("Dot", &gp_Vec::Dot, "Other"_a)
.def("Crossed", &gp_Vec::Crossed, "Right"_a, OCP_RETURN_COPY)
.def("Normalize", &gp_Vec::Normalize)
.def("Normalized", &gp_Vec::Normalized, OCP_RETURN_COPY)
.def("Reversed", &gp_Vec::Reversed, OCP_RETURN_COPY)
.def("Multiplied", &gp_Vec::Multiplied, "Scalar"_a, OCP_RETURN_COPY)
.def("Angle", &gp_Vec::Angle, "Other"_a)
.def("Transform", &gp_Vec::Transform, "T"_a)
.def("Transformed", &gp_Vec::Transformed, "T"_a, OCP_RETURN_COPY);
nb::class_<gp_Dir>(m, "gp_Dir")
.def(nb::init<>())
.def(nb::init<Standard_Real, Standard_Real, Standard_Real>(), "Xv"_a,
"Yv"_a, "Zv"_a)
.def(nb::init<const gp_Vec &>(), "V"_a)
.def("X", &gp_Dir::X)
.def("Y", &gp_Dir::Y)
.def("Z", &gp_Dir::Z)
.def("Dot", &gp_Dir::Dot, "Other"_a)
.def("Crossed", &gp_Dir::Crossed, "Right"_a, OCP_RETURN_COPY)
.def("Angle", &gp_Dir::Angle, "Other"_a)
.def("IsParallel", &gp_Dir::IsParallel, "Other"_a, "AngularTolerance"_a)
.def("Reversed", &gp_Dir::Reversed, OCP_RETURN_COPY)
.def("Reverse", &gp_Dir::Reverse)
.def("Transform", &gp_Dir::Transform, "T"_a)
.def("Transformed", &gp_Dir::Transformed, "T"_a, OCP_RETURN_COPY);
nb::class_<gp_Ax1>(m, "gp_Ax1")
.def(nb::init<>())
.def(nb::init<const gp_Pnt &, const gp_Dir &>(), "P"_a, "V"_a)
.def("Location", &gp_Ax1::Location, OCP_RETURN_COPY)
.def("Direction", &gp_Ax1::Direction, OCP_RETURN_COPY)
.def("SetLocation", &gp_Ax1::SetLocation, "P"_a)
.def("SetDirection", &gp_Ax1::SetDirection, "V"_a)
.def("Reversed", &gp_Ax1::Reversed, OCP_RETURN_COPY);
nb::class_<gp_Trsf>(m, "gp_Trsf")
.def(nb::init<>())
.def("SetTranslation",
nb::overload_cast<const gp_Vec &>(&gp_Trsf::SetTranslation),
"V"_a)
.def("SetTranslation",
nb::overload_cast<const gp_Pnt &, const gp_Pnt &>(
&gp_Trsf::SetTranslation),
"P1"_a, "P2"_a)
.def("SetRotation",
nb::overload_cast<const gp_Ax1 &, Standard_Real>(
&gp_Trsf::SetRotation),
"A1"_a, "Ang"_a)
.def("SetScale", &gp_Trsf::SetScale, "P"_a, "S"_a)
.def("SetMirror", nb::overload_cast<const gp_Ax1 &>(&gp_Trsf::SetMirror),
"A1"_a)
.def("Form", &gp_Trsf::Form)
.def("ScaleFactor", &gp_Trsf::ScaleFactor)
.def("TranslationPart", &gp_Trsf::TranslationPart, OCP_RETURN_COPY)
.def("Value", &gp_Trsf::Value, "Row"_a, "Col"_a)
.def("Inverted", &gp_Trsf::Inverted, OCP_RETURN_COPY)
.def("Multiplied", &gp_Trsf::Multiplied, "T"_a, OCP_RETURN_COPY)
.def("Multiply", &gp_Trsf::Multiply, "T"_a)
.def("PreMultiply", &gp_Trsf::PreMultiply, "T"_a)
.def("Invert", &gp_Trsf::Invert);
}

0
tests/__init__.py Normal file
View File

51
tests/conftest.py Normal file
View File

@@ -0,0 +1,51 @@
"""Shared fixtures.
Inc 0 binds no modelling API, so shapes for the tests are built the long way:
a compound assembled through BRep_Builder, and shapes read back from the
committed .brep fixtures (tests/data), which were produced by the *stock*
wheel — see tools/gen_fixtures.py.
"""
from __future__ import annotations
import io
import json
import pathlib
import pytest
from OCP.BinTools import BinTools
from OCP.TopoDS import TopoDS_Shape
DATA = pathlib.Path(__file__).parent / "data"
def load_brep(path: pathlib.Path) -> TopoDS_Shape:
shape = TopoDS_Shape()
with path.open("rb") as fh:
BinTools.Read_s(shape, io.BytesIO(fh.read()))
return shape
@pytest.fixture(scope="session")
def manifest() -> dict:
path = DATA / "manifest.json"
if not path.exists():
pytest.skip("fixtures not generated — run tools/gen_fixtures.py")
return json.loads(path.read_text())
@pytest.fixture(scope="session")
def fixture_shapes(manifest) -> dict[str, TopoDS_Shape]:
return {name: load_brep(DATA / f"{name}.brep") for name in manifest["shapes"]}
@pytest.fixture
def a_face(fixture_shapes):
"""First face of the meshed box fixture."""
from OCP.TopAbs import TopAbs_FACE
from OCP.TopExp import TopExp
from OCP.TopTools import TopTools_IndexedMapOfShape
faces = TopTools_IndexedMapOfShape()
TopExp.MapShapes_s(fixture_shapes["box_meshed"], TopAbs_FACE, faces)
return faces.FindKey(1)

BIN
tests/data/box.brep Normal file

Binary file not shown.

BIN
tests/data/box_meshed.brep Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
tests/data/fused.brep Normal file

Binary file not shown.

Binary file not shown.

36
tests/data/manifest.json Normal file
View File

@@ -0,0 +1,36 @@
{
"generated_by": "cadquery-ocp-novtk",
"occt_version": "7.9.3.1",
"shapes": {
"box": {
"sha256": "d83ce546f116c93596552ed7628736843c05a6455c13d9e209a0c2830bd6cc35",
"size": 4494,
"faces": 6
},
"box_meshed": {
"sha256": "b656381c0fd05aae37256e36e15d038a80c5f10df0a4ef4ee570192d23772896",
"size": 6931,
"faces": 6
},
"fused": {
"sha256": "3535742144c0a789f06b6a1950c5cecfc723e9bfeaf3b3b5cb7c38c78027661e",
"size": 10602,
"faces": 12
},
"cut_cylinder": {
"sha256": "f91914a18d5faa9ffbb213f46257f00d3a08c09d58f7de96820d7f7431391e8f",
"size": 6058,
"faces": 7
},
"located_compound": {
"sha256": "4214e4a24a419d0ee7cdc1db113740a645d37ff696a18924bbed602535a8896f",
"size": 4618,
"faces": 12
},
"empty_compound": {
"sha256": "396c108ce1871b60b5076fddd4baa0266797a2294b4a401a689a9ed7ca1e2de0",
"size": 171,
"faces": 0
}
}
}

View File

@@ -0,0 +1,86 @@
"""The Inc 0 exit gate: BREP serialisation is byte-identical to the stock wheel.
Every subprocess pool moves shapes as BinTools bytes, so this is the IPC
contract. It is also a storage contract: cad/derive.py content-addresses BREP
payloads (payloads/derived/brep/<sha256>.brep) and stores the ref inside the
document, so bytes that differ would silently rewrite every derived payload.
The comparison is our-rewrite vs stock-rewrite. tools/gen_fixtures.py already
proved stock is idempotent for each fixture, which is what makes the stored
digest a fixed reference rather than one arbitrary encoding of many.
"""
from __future__ import annotations
import hashlib
import io
import pytest
from OCP.BinTools import BinTools
from OCP.TopAbs import TopAbs_FACE
from OCP.TopExp import TopExp
from OCP.TopoDS import TopoDS_Shape
from OCP.TopTools import TopTools_IndexedMapOfShape
from .conftest import DATA
def _rewrite(data: bytes) -> bytes:
shape = TopoDS_Shape()
BinTools.Read_s(shape, io.BytesIO(data))
out = io.BytesIO()
BinTools.Write_s(shape, out)
return out.getvalue()
def test_roundtrip_is_byte_identical(manifest):
for name, expected in manifest["shapes"].items():
original = (DATA / f"{name}.brep").read_bytes()
assert hashlib.sha256(original).hexdigest() == expected["sha256"], (
f"{name}.brep does not match the manifest — regenerate fixtures"
)
assert hashlib.sha256(_rewrite(original)).hexdigest() == expected["sha256"], (
f"{name}: rewritten bytes differ from the stock wheel's"
)
def test_face_map_ordering_survives_the_roundtrip(manifest, fixture_shapes):
# Face identity throughout the topology code is the map ordinal, so a
# round trip that renumbered faces would silently retarget every anchor.
for name, shape in fixture_shapes.items():
faces = TopTools_IndexedMapOfShape()
TopExp.MapShapes_s(shape, TopAbs_FACE, faces)
assert faces.Extent() == manifest["shapes"][name]["faces"], name
def test_empty_compound_survives():
shape = TopoDS_Shape()
BinTools.Read_s(shape, io.BytesIO((DATA / "empty_compound.brep").read_bytes()))
assert not shape.IsNull()
def test_extension_bytes_api_matches_the_drop_in(fixture_shapes):
# n3xd_ocp.bintools exists to skip the BytesIO detour on the pool paths; it
# is only usable there if it produces the very same bytes.
from n3xd_ocp import bintools
for name, shape in fixture_shapes.items():
buf = io.BytesIO()
BinTools.Write_s(shape, buf)
assert bintools.write_bytes(shape) == buf.getvalue(), name
restored = bintools.read_bytes(buf.getvalue())
assert bintools.write_bytes(restored) == buf.getvalue(), name
@pytest.mark.parametrize("name", ["box", "fused"])
def test_write_appends_to_the_stream_position(name):
# BinTools.Write_s writes through a file-like object; a caller that already
# wrote a header must still find its bytes intact.
shape = TopoDS_Shape()
BinTools.Read_s(shape, io.BytesIO((DATA / f"{name}.brep").read_bytes()))
buf = io.BytesIO()
buf.write(b"HEADER")
BinTools.Write_s(shape, buf)
assert buf.getvalue().startswith(b"HEADER")

59
tests/test_forksafety.py Normal file
View File

@@ -0,0 +1,59 @@
"""Fork safety — what keeps cad_pool's forkserver cheap.
cad_pool preloads the OCP-importing modules into a forkserver and forks per
job (~30 ms) instead of spawning and re-importing OCCT (~1.3 s). That is only
sound if importing this binding starts no threads and leaves no state that a
fork would invalidate.
"""
from __future__ import annotations
import multiprocessing
import threading
def test_no_threads_after_import():
import OCP # noqa: F401
assert threading.active_count() == 1, threading.enumerate()
def _child(conn):
# Runs after the fork: exercise the kernel to prove the inherited image is
# usable rather than merely present.
import io
from OCP.BinTools import BinTools
from OCP.TopAbs import TopAbs_FACE
from OCP.TopExp import TopExp
from OCP.TopoDS import TopoDS_Shape
from OCP.TopTools import TopTools_IndexedMapOfShape
from .conftest import DATA
shape = TopoDS_Shape()
BinTools.Read_s(shape, io.BytesIO((DATA / "fused.brep").read_bytes()))
faces = TopTools_IndexedMapOfShape()
TopExp.MapShapes_s(shape, TopAbs_FACE, faces)
out = io.BytesIO()
BinTools.Write_s(shape, out)
conn.send((faces.Extent(), len(out.getvalue())))
conn.close()
def test_forkserver_child_can_use_the_kernel():
import OCP # noqa: F401
ctx = multiprocessing.get_context("forkserver")
ctx.set_forkserver_preload(["OCP", "OCP.TopoDS", "OCP.BinTools"])
parent, child = ctx.Pipe()
proc = ctx.Process(target=_child, args=(child,))
proc.start()
faces, size = parent.recv()
proc.join(30)
assert proc.exitcode == 0
assert faces == 12
assert size > 0

147
tests/test_handles.py Normal file
View File

@@ -0,0 +1,147 @@
"""The handle model (occt_handle.h) — the spike that gates all surface work.
OCCT transients are reference-counted intrusively, and this binding gives each
Python wrapper exactly one of those references, released at deallocation. The
properties below are what the rest of the binding is allowed to assume:
* an object crossing to Python and back keeps its identity while alive;
* either side may drop its reference first without taking the object away
from the other;
* counts stay balanced when a call raises, and across many cycles.
"""
from __future__ import annotations
import gc
import os
import pytest
from n3xd_ocp import _debug
from OCP.BRep import BRep_Tool
from OCP.Poly import Poly_Triangulation
from OCP.TopAbs import TopAbs_FACE
from OCP.TopExp import TopExp
from OCP.TopLoc import TopLoc_Location
from OCP.TopoDS import TopoDS
from OCP.TopTools import TopTools_IndexedMapOfShape
@pytest.fixture
def triangulation(fixture_shapes):
faces = TopTools_IndexedMapOfShape()
TopExp.MapShapes_s(fixture_shapes["box_meshed"], TopAbs_FACE, faces)
tri = BRep_Tool.Triangulation_s(TopoDS.Face_s(faces.FindKey(1)),
TopLoc_Location())
assert tri is not None, "fixture should carry a mesh"
return tri
def test_python_constructed_transient_is_heap_owned():
# nb::init<> would place the object inside the Python instance and let OCCT
# delete storage it does not own; ocp_new heap-allocates instead.
tri = Poly_Triangulation(4, 2, False, False)
assert tri.NbNodes() == 4
assert _debug.refcount(tri) >= 1
def test_kernel_returned_transient_survives_its_shape(fixture_shapes):
faces = TopTools_IndexedMapOfShape()
TopExp.MapShapes_s(fixture_shapes["box_meshed"], TopAbs_FACE, faces)
tri = BRep_Tool.Triangulation_s(TopoDS.Face_s(faces.FindKey(1)),
TopLoc_Location())
del faces
gc.collect()
assert tri.NbNodes() > 0 # touches the memory
def test_roundtrip_preserves_wrapper_identity(triangulation):
assert _debug.roundtrip(triangulation) is triangulation
def test_cpp_side_reference_keeps_the_object_alive():
tri = Poly_Triangulation(8, 4, False, False)
slot = _debug.hold(tri)
del tri
gc.collect()
assert _debug.held_is_alive(slot)
assert _debug.held_nb_nodes(slot) == 8 # dereferences it
_debug.release_held()
def test_python_reference_survives_the_cpp_side_dropping_its_own():
tri = Poly_Triangulation(6, 2, False, False)
_debug.hold(tri)
_debug.release_held()
gc.collect()
assert tri.NbNodes() == 6
def test_refcount_is_balanced_across_conversions(triangulation):
before = _debug.refcount(triangulation)
for _ in range(1000):
_debug.roundtrip(triangulation)
gc.collect()
assert _debug.refcount(triangulation) == before
def test_refcount_is_balanced_when_a_call_raises(triangulation):
before = _debug.refcount(triangulation)
for _ in range(100):
with pytest.raises(TypeError):
# Argument converts, then the call fails on arity.
_debug.roundtrip(triangulation, "unexpected")
gc.collect()
assert _debug.refcount(triangulation) == before
def _rss_kb() -> int:
with open("/proc/self/status") as fh:
for line in fh:
if line.startswith("VmRSS:"):
return int(line.split()[1])
raise RuntimeError("VmRSS not found")
@pytest.mark.skipif(
os.environ.get("N3XD_OCP_ASAN") == "1",
reason="ASAN quarantines freed memory, so RSS is not a leak signal there",
)
def test_many_create_destroy_cycles_do_not_leak():
# ASAN covers memory *safety*; this covers *growth*. One leaked handle per
# cycle would keep every triangulation alive — visible as RSS climbing with
# the iteration count rather than settling.
def cycle(n: int) -> None:
for _ in range(n):
tri = Poly_Triangulation(64, 32, False, False)
_debug.roundtrip(tri)
del tri
cycle(2_000) # let allocator arenas reach steady state first
gc.collect()
baseline = _rss_kb()
cycle(50_000)
gc.collect()
growth = _rss_kb() - baseline
# Leaking these would cost megabytes; a few hundred KB of allocator noise
# is expected.
assert growth < 4_000, f"RSS grew {growth} KB across 50k cycles"
def test_null_handle_maps_to_none(fixture_shapes):
# An unmeshed face has no triangulation; OCCT returns a null handle and the
# caster must present that as None rather than a wrapper around nullptr.
faces = TopTools_IndexedMapOfShape()
TopExp.MapShapes_s(fixture_shapes["box"], TopAbs_FACE, faces)
tri = BRep_Tool.Triangulation_s(TopoDS.Face_s(faces.FindKey(1)),
TopLoc_Location())
assert tri is None
def test_none_converts_to_a_null_handle():
assert _debug.roundtrip(None) is None
assert _debug.refcount(None) == 0

62
tests/test_import.py Normal file
View File

@@ -0,0 +1,62 @@
"""Import surface: every Inc 0 module present, and nothing started a thread."""
import sys
import threading
import OCP
def test_version_and_kernel():
assert OCP.__version__.startswith(OCP.__occt_version__), (
f"wheel version {OCP.__version__} must be prefixed by the OCCT it "
f"wraps ({OCP.__occt_version__})"
)
def test_every_module_is_importable_and_attribute_reachable():
for name in OCP._OCP.__all_modules__:
assert f"OCP.{name}" in sys.modules, f"OCP.{name} not in sys.modules"
assert getattr(OCP, name) is sys.modules[f"OCP.{name}"]
def test_inc0_modules_present():
expected = {
"Standard", "StdFail", "gp", "TopAbs", "TopLoc", "TopoDS",
"TopTools", "TopExp", "Poly", "BRep", "BinTools",
}
assert expected <= set(OCP._OCP.__all_modules__)
def test_classes_report_their_real_module():
from OCP.TopoDS import TopoDS_Shape
# Anything importing OCP.TopoDS expects the class to belong there — a flat
# extension namespace would break repr and stub generation alike.
assert TopoDS_Shape.__module__ == "OCP.TopoDS"
def test_import_starts_no_threads():
# cad_pool forks from a forkserver whose preload imports OCP. A thread
# started at import would not survive the fork, so this invariant is what
# keeps fillet isolation at ~30 ms per job instead of a ~1.3 s respawn.
assert threading.active_count() == 1, threading.enumerate()
def test_enums_export_their_values():
from OCP.TopAbs import TopAbs_FACE, TopAbs_ShapeEnum
assert TopAbs_FACE == TopAbs_ShapeEnum.TopAbs_FACE
assert int(TopAbs_FACE) == 4 # OCCT's numeric value, relied on by callers
def test_static_methods_carry_the_s_suffix():
from OCP.BRep import BRep_Tool
from OCP.TopExp import TopExp
from OCP.TopoDS import TopoDS
for holder, name in [
(TopExp, "MapShapes_s"),
(TopoDS, "Face_s"),
(BRep_Tool, "Triangulation_s"),
]:
assert hasattr(holder, name), f"{holder.__name__}.{name} missing"

View File

@@ -0,0 +1,100 @@
"""Shape hashing and equality — the contract cad/topology/geom_memo.py rests on.
Verified against the stock wheel before being replicated here: upstream binds
__hash__ (TShape + Location) and leaves __eq__ at Python's default identity
comparison. That pairing is deliberate on our side too. geom_memo buckets on
hash(face) and disambiguates with IsSame precisely because == cannot be
trusted; binding __eq__ to IsEqual would collapse entries that memo expects to
keep apart, changing behaviour while looking like an improvement.
"""
from __future__ import annotations
from OCP.gp import gp_Trsf, gp_Vec
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE
from OCP.TopExp import TopExp
from OCP.TopLoc import TopLoc_Location
from OCP.TopTools import TopTools_IndexedMapOfShape
def _faces(shape):
faces = TopTools_IndexedMapOfShape()
TopExp.MapShapes_s(shape, TopAbs_FACE, faces)
return faces
def test_re_extracted_face_hashes_equal_and_is_same(fixture_shapes):
shape = fixture_shapes["box"]
f1 = _faces(shape).FindKey(1)
f2 = _faces(shape).FindKey(1)
assert f1 is not f2 # separate extractions, separate wrappers
assert hash(f1) == hash(f2)
assert f1.IsSame(f2)
assert f1.IsEqual(f2)
def test_eq_is_identity_not_isequal(fixture_shapes):
shape = fixture_shapes["box"]
f1 = _faces(shape).FindKey(1)
f2 = _faces(shape).FindKey(1)
# Matches the stock wheel: equal hashes, unequal objects.
assert f1 != f2
assert f1 == f1
def test_moved_copy_hashes_differently(fixture_shapes):
trsf = gp_Trsf()
trsf.SetTranslation(gp_Vec(1.0, 0.0, 0.0))
face = _faces(fixture_shapes["box"]).FindKey(1)
moved = face.Moved(TopLoc_Location(trsf))
assert hash(moved) != hash(face)
assert not face.IsSame(moved)
def test_orientation_is_not_part_of_the_hash(fixture_shapes):
# Area and centre of mass are orientation-independent, which is why the
# memo's key needs no orientation component.
face = _faces(fixture_shapes["box"]).FindKey(1)
reversed_face = face.Reversed()
assert hash(reversed_face) == hash(face)
assert face.IsSame(reversed_face)
assert not face.IsEqual(reversed_face)
def test_subshapes_outlive_their_container(fixture_shapes):
"""The lifetime class that segfaulted a process-global face memo."""
faces = _faces(fixture_shapes["fused"])
picked = [faces.FindKey(i) for i in range(1, faces.Extent() + 1)]
del faces
# Every wrapper owns its own copy, so the map's death is irrelevant.
assert all(not f.IsNull() for f in picked)
assert len({hash(f) for f in picked}) == len(picked)
def test_explorer_results_outlive_the_explorer(fixture_shapes):
from OCP.TopExp import TopExp_Explorer
exp = TopExp_Explorer(fixture_shapes["box"], TopAbs_EDGE)
edges = []
while exp.More():
edges.append(exp.Current())
exp.Next()
del exp
assert len(edges) == 24 # a box: 12 edges, each shared by two faces
assert all(not e.IsNull() for e in edges)
def test_indexed_map_contains_uses_is_same(fixture_shapes):
shape = fixture_shapes["box"]
faces = _faces(shape)
other = _faces(shape).FindKey(3)
assert faces.Contains(other)
assert faces.FindIndex(other) == 3

172
tools/gen_fixtures.py Normal file
View File

@@ -0,0 +1,172 @@
"""Generate the .brep byte-identity fixtures.
Runs under the STOCK cadquery-ocp-novtk wheel, not this binding — the fixtures
are the reference the Inc 0 gate compares against. From the app checkout:
app/.venv/bin/python ../ocp/tools/gen_fixtures.py
Two properties are recorded, and the first is checked here rather than assumed:
* stock is idempotent — reading a shape and writing it back reproduces the
same bytes. Without that the gate would be comparing against a moving
target, and a mismatch would say nothing.
* the digest of those bytes, which is what the gate reproduces. This matters
beyond IPC: cad/derive.py content-addresses BREP payloads as
payloads/derived/brep/<sha256>.brep and stores the ref in the document, so
a binding that serialises differently rewrites every derived payload.
Face counts are recorded alongside, pinning the map ordering that face and edge
identity depend on throughout the topology code.
"""
from __future__ import annotations
import hashlib
import io
import json
import pathlib
import sys
from OCP.BinTools import BinTools
from OCP.BRep import BRep_Builder
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder
from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
from OCP.TopAbs import TopAbs_FACE
from OCP.TopExp import TopExp
from OCP.TopLoc import TopLoc_Location
from OCP.TopoDS import TopoDS_Compound, TopoDS_Shape
from OCP.TopTools import TopTools_IndexedMapOfShape
OUT = pathlib.Path(__file__).resolve().parent.parent / "tests" / "data"
def _box(dx=10.0, dy=20.0, dz=30.0) -> TopoDS_Shape:
return BRepPrimAPI_MakeBox(dx, dy, dz).Shape()
def _meshed_box() -> TopoDS_Shape:
shape = _box()
BRepMesh_IncrementalMesh(shape, 0.1, False, 0.5, True)
return shape
def _fused() -> TopoDS_Shape:
op = BRepAlgoAPI_Fuse()
from OCP.TopTools import TopTools_ListOfShape
args, tools = TopTools_ListOfShape(), TopTools_ListOfShape()
args.Append(_box())
tools.Append(
BRepPrimAPI_MakeBox(gp_Pnt(5, 5, 5), 20.0, 20.0, 20.0).Shape()
)
op.SetArguments(args)
op.SetTools(tools)
op.Build()
return op.Shape()
def _cut_cylinder() -> TopoDS_Shape:
op = BRepAlgoAPI_Cut()
from OCP.TopTools import TopTools_ListOfShape
args, tools = TopTools_ListOfShape(), TopTools_ListOfShape()
args.Append(_box())
tools.Append(
BRepPrimAPI_MakeCylinder(
gp_Ax2(gp_Pnt(5, 10, 0), gp_Dir(0, 0, 1)), 3.0, 30.0
).Shape()
)
op.SetArguments(args)
op.SetTools(tools)
op.Build()
return op.Shape()
def _located_compound() -> TopoDS_Shape:
"""Exercises the location/TShape sharing part of the format."""
builder = BRep_Builder()
comp = TopoDS_Compound()
builder.MakeCompound(comp)
base = _box()
builder.Add(comp, base)
trsf = gp_Trsf()
trsf.SetTranslation(gp_Vec(50.0, 0.0, 0.0))
builder.Add(comp, base.Moved(TopLoc_Location(trsf)))
return comp
def _empty_compound() -> TopoDS_Shape:
builder = BRep_Builder()
comp = TopoDS_Compound()
builder.MakeCompound(comp)
return comp
SHAPES = {
"box": _box,
"box_meshed": _meshed_box,
"fused": _fused,
"cut_cylinder": _cut_cylinder,
"located_compound": _located_compound,
"empty_compound": _empty_compound,
}
def write_bytes(shape: TopoDS_Shape) -> bytes:
buf = io.BytesIO()
BinTools.Write_s(shape, buf)
return buf.getvalue()
def read_shape(data: bytes) -> TopoDS_Shape:
shape = TopoDS_Shape()
BinTools.Read_s(shape, io.BytesIO(data))
return shape
def face_count(shape: TopoDS_Shape) -> int:
faces = TopTools_IndexedMapOfShape()
TopExp.MapShapes_s(shape, TopAbs_FACE, faces)
return faces.Extent()
def main() -> int:
import OCP
OUT.mkdir(parents=True, exist_ok=True)
manifest: dict = {
"generated_by": "cadquery-ocp-novtk",
"occt_version": getattr(OCP, "__version__", "unknown"),
"shapes": {},
}
for name, build in SHAPES.items():
shape = build()
data = write_bytes(shape)
# The gate is "our rewrite == stock rewrite". If stock itself is not
# idempotent for a fixture, that fixture cannot serve as a reference.
rewritten = write_bytes(read_shape(data))
if rewritten != data:
print(f"FAIL {name}: stock is not idempotent", file=sys.stderr)
return 1
(OUT / f"{name}.brep").write_bytes(data)
manifest["shapes"][name] = {
"sha256": hashlib.sha256(data).hexdigest(),
"size": len(data),
"faces": face_count(shape),
}
print(f"{name}: {len(data)} bytes, {manifest['shapes'][name]['faces']} faces")
(OUT / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
print(f"\nwrote {len(SHAPES)} fixtures + manifest to {OUT}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

154
tools/inventory.py Normal file
View File

@@ -0,0 +1,154 @@
"""Symbol inventory: what the app imports from OCP, and what this wheel provides.
Two jobs, one source of truth:
--emit parse the app's sources for every `from OCP.<mod> import <name>`
plus the attributes reached on those names, and write inventory.json.
That file is the coverage work-queue for the remaining increments
and, re-run later, the drift detector for newly used symbols.
--check import the installed OCP and report what the inventory asks for but
the wheel does not provide, grouped by module.
Static analysis only reaches names reached through an imported symbol
(`TopExp.MapShapes_s`), not methods called on instances (`shape.IsSame(...)`) —
those are covered by running the app's own suite in the parity venv, where a
gap is a loud AttributeError. So a green --check is necessary, not sufficient.
python tools/inventory.py --emit --app ../app/backend
python tools/inventory.py --check
"""
from __future__ import annotations
import argparse
import ast
import collections
import json
import pathlib
import sys
HERE = pathlib.Path(__file__).resolve().parent
DEFAULT_INVENTORY = HERE.parent / "inventory.json"
DEFAULT_APP = HERE.parent.parent / "app" / "backend"
def scan(app_root: pathlib.Path) -> dict:
"""Collect {module: {symbol: [attributes reached on it]}} from the app."""
modules: dict[str, dict[str, set[str]]] = collections.defaultdict(
lambda: collections.defaultdict(set)
)
files = 0
for path in sorted(app_root.rglob("*.py")):
if "__pycache__" in path.parts:
continue
try:
tree = ast.parse(path.read_text(encoding="utf-8"))
except (SyntaxError, UnicodeDecodeError):
continue
# local alias -> (module, original name)
local: dict[str, tuple[str, str]] = {}
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module:
if node.module == "OCP" or node.module.startswith("OCP."):
mod = node.module[4:] or "OCP"
for alias in node.names:
local[alias.asname or alias.name] = (mod, alias.name)
modules[mod][alias.name] # noqa: B018 — create the entry
if local:
files += 1
for node in ast.walk(tree):
if (
isinstance(node, ast.Attribute)
and isinstance(node.value, ast.Name)
and node.value.id in local
):
mod, name = local[node.value.id]
modules[mod][name].add(node.attr)
return {
"app_root": str(app_root),
"files_importing_ocp": files,
"modules": {
mod: {name: sorted(attrs) for name, attrs in sorted(syms.items())}
for mod, syms in sorted(modules.items())
},
}
def emit(app_root: pathlib.Path, out: pathlib.Path) -> int:
data = scan(app_root)
out.write_text(json.dumps(data, indent=2) + "\n")
n_mod = len(data["modules"])
n_sym = sum(len(v) for v in data["modules"].values())
print(f"{n_sym} symbols across {n_mod} modules "
f"({data['files_importing_ocp']} files) -> {out}")
return 0
def check(inventory: pathlib.Path) -> int:
import importlib
data = json.loads(inventory.read_text())
missing: dict[str, list[str]] = {}
present = 0
for mod, symbols in data["modules"].items():
try:
m = importlib.import_module(f"OCP.{mod}")
except ImportError:
missing[mod] = [f"<module missing> ({len(symbols)} symbols)"]
continue
for name, attrs in symbols.items():
obj = getattr(m, name, None)
if obj is None:
missing.setdefault(mod, []).append(name)
continue
present += 1
for attr in attrs:
if not hasattr(obj, attr):
missing.setdefault(mod, []).append(f"{name}.{attr}")
total = sum(len(v) for v in data["modules"].values())
print(f"{present}/{total} imported symbols available")
if missing:
print(f"\nmissing, by module ({len(missing)} modules):")
for mod in sorted(missing):
print(f" {mod}: {', '.join(sorted(missing[mod]))}")
return 1
print("complete")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--emit", action="store_true", help="rewrite the inventory")
ap.add_argument("--check", action="store_true",
help="compare the installed OCP against the inventory")
ap.add_argument("--app", type=pathlib.Path, default=DEFAULT_APP)
ap.add_argument("--inventory", type=pathlib.Path, default=DEFAULT_INVENTORY)
args = ap.parse_args()
if args.emit:
if not args.app.is_dir():
print(f"app sources not found: {args.app}", file=sys.stderr)
return 2
return emit(args.app, args.inventory)
if args.check:
if not args.inventory.exists():
print(f"no inventory at {args.inventory}; run --emit first",
file=sys.stderr)
return 2
return check(args.inventory)
ap.print_help()
return 2
if __name__ == "__main__":
raise SystemExit(main())

53
tools/parity_venv.sh Executable file
View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Set up a side environment where the app runs against n3xd-ocp instead of the
# stock wheel.
#
# The app's manifests are never touched: a swap is per-environment because both
# distributions own the OCP/ import path and a process can hold only one OCCT
# build. main therefore keeps resolving cadquery-ocp-novtk until the Phase 10C
# cutover, and this venv is how parity is proven in the meantime.
#
# tools/parity_venv.sh install from the Gitea registry
# tools/parity_venv.sh --local install the local wheelhouse build
#
# Then run whichever slice of the app suite the current increment claims:
# cd ../app && UV_PROJECT_ENVIRONMENT=.venv-ocp-parity uv run pytest backend/tests/test_geom_memo.py
set -euo pipefail
HERE=$(cd "$(dirname "$0")" && pwd)
OCP_REPO=$(dirname "$HERE")
APP="${APP_DIR:-$(dirname "$OCP_REPO")/app}"
VENV_NAME="${VENV_NAME:-.venv-ocp-parity}"
INDEX="https://git.stroblme.de/api/packages/N3XD/pypi/simple/"
SOURCE="registry"
[ "${1:-}" = "--local" ] && SOURCE="local"
cd "$APP"
export UV_PROJECT_ENVIRONMENT="$VENV_NAME"
echo "--- syncing the app's dependencies into $VENV_NAME ---"
uv sync --all-groups
# Removal must precede installation: both distributions install a top-level
# OCP/, so installing over the stock wheel would leave a half-overwritten mix.
echo "--- removing the stock wheel ---"
uv pip uninstall cadquery-ocp-novtk || true
echo "--- installing n3xd-ocp ($SOURCE) ---"
if [ "$SOURCE" = "local" ]; then
uv pip install "$OCP_REPO"/wheelhouse/*.whl
else
uv pip install --index-url "$INDEX" --prerelease=allow n3xd-ocp
fi
echo "--- sanity ---"
uv run python -c "
import OCP
print('OCP', OCP.__version__, '/ OCCT', OCP.__occt_version__)
print('modules:', len(OCP._OCP.__all_modules__))
"
echo "--- coverage against the app's symbol inventory ---"
uv run python "$HERE/inventory.py" --check || \
echo "(incomplete coverage is expected until the increments land)"