From 61398527680f566937b5138739dfef3d2b82d433 Mon Sep 17 00:00:00 2001 From: stroblme Date: Mon, 10 Aug 2026 16:10:28 +0200 Subject: [PATCH] Phase 10A/10B Inc 0: build system, handle model, first module surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 .N is asserted against the OCCT found, keeping occt_version() truthful. - occt_handle.h: type caster for opencascade::handle 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. --- .gitignore | 1 + CMakeLists.txt | 64 +++++++ Makefile | 56 ++++++ README.md | 43 +++-- docs/adding-symbols.md | 99 +++++++++++ docs/building.md | 110 ++++++++++++ docs/design.md | 195 +++++++++++++++++++++ inventory.json | 291 +++++++++++++++++++++++++++++++ occt/Dockerfile | 79 +++++++++ pyproject.toml | 46 +++++ python/OCP/__init__.py | 25 +++ python/OCP/py.typed | 0 python/n3xd_ocp/__init__.py | 21 +++ python/n3xd_ocp/py.typed | 0 scripts/_env.sh | 28 +++ scripts/asan.sh | 49 ++++++ scripts/build_wheel.sh | 41 +++++ scripts/in_container_test.sh | 16 ++ scripts/stubgen.sh | 39 +++++ src/common/occt_exceptions.cpp | 109 ++++++++++++ src/common/occt_exceptions.h | 20 +++ src/common/occt_handle.h | 124 +++++++++++++ src/common/occt_module.cpp | 28 +++ src/common/occt_module.h | 49 ++++++ src/common/occt_policies.h | 35 ++++ src/common/occt_stream.h | 41 +++++ src/common/occt_transient.h | 42 +++++ src/core.cpp | 59 +++++++ src/ext/ext_bintools.cpp | 53 ++++++ src/ext/ext_core.cpp | 25 +++ src/ext/ext_debug.cpp | 78 +++++++++ src/modules/mod_BRep.cpp | 73 ++++++++ src/modules/mod_BinTools.cpp | 51 ++++++ src/modules/mod_Poly.cpp | 56 ++++++ src/modules/mod_Standard.cpp | 25 +++ src/modules/mod_TopAbs.cpp | 44 +++++ src/modules/mod_TopExp.cpp | 48 +++++ src/modules/mod_TopLoc.cpp | 24 +++ src/modules/mod_TopTools.cpp | 100 +++++++++++ src/modules/mod_TopoDS.cpp | 114 ++++++++++++ src/modules/mod_gp.cpp | 138 +++++++++++++++ tests/__init__.py | 0 tests/conftest.py | 51 ++++++ tests/data/box.brep | Bin 0 -> 4494 bytes tests/data/box_meshed.brep | Bin 0 -> 6931 bytes tests/data/cut_cylinder.brep | Bin 0 -> 6058 bytes tests/data/empty_compound.brep | Bin 0 -> 171 bytes tests/data/fused.brep | Bin 0 -> 10602 bytes tests/data/located_compound.brep | Bin 0 -> 4618 bytes tests/data/manifest.json | 36 ++++ tests/test_bintools_gate.py | 86 +++++++++ tests/test_forksafety.py | 59 +++++++ tests/test_handles.py | 147 ++++++++++++++++ tests/test_import.py | 62 +++++++ tests/test_shape_identity.py | 100 +++++++++++ tools/gen_fixtures.py | 172 ++++++++++++++++++ tools/inventory.py | 154 ++++++++++++++++ tools/parity_venv.sh | 53 ++++++ 58 files changed, 3446 insertions(+), 13 deletions(-) create mode 100644 CMakeLists.txt create mode 100644 Makefile create mode 100644 docs/adding-symbols.md create mode 100644 docs/building.md create mode 100644 docs/design.md create mode 100644 inventory.json create mode 100644 occt/Dockerfile create mode 100644 pyproject.toml create mode 100644 python/OCP/__init__.py create mode 100644 python/OCP/py.typed create mode 100644 python/n3xd_ocp/__init__.py create mode 100644 python/n3xd_ocp/py.typed create mode 100755 scripts/_env.sh create mode 100755 scripts/asan.sh create mode 100755 scripts/build_wheel.sh create mode 100755 scripts/in_container_test.sh create mode 100755 scripts/stubgen.sh create mode 100644 src/common/occt_exceptions.cpp create mode 100644 src/common/occt_exceptions.h create mode 100644 src/common/occt_handle.h create mode 100644 src/common/occt_module.cpp create mode 100644 src/common/occt_module.h create mode 100644 src/common/occt_policies.h create mode 100644 src/common/occt_stream.h create mode 100644 src/common/occt_transient.h create mode 100644 src/core.cpp create mode 100644 src/ext/ext_bintools.cpp create mode 100644 src/ext/ext_core.cpp create mode 100644 src/ext/ext_debug.cpp create mode 100644 src/modules/mod_BRep.cpp create mode 100644 src/modules/mod_BinTools.cpp create mode 100644 src/modules/mod_Poly.cpp create mode 100644 src/modules/mod_Standard.cpp create mode 100644 src/modules/mod_TopAbs.cpp create mode 100644 src/modules/mod_TopExp.cpp create mode 100644 src/modules/mod_TopLoc.cpp create mode 100644 src/modules/mod_TopTools.cpp create mode 100644 src/modules/mod_TopoDS.cpp create mode 100644 src/modules/mod_gp.cpp create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/data/box.brep create mode 100644 tests/data/box_meshed.brep create mode 100644 tests/data/cut_cylinder.brep create mode 100644 tests/data/empty_compound.brep create mode 100644 tests/data/fused.brep create mode 100644 tests/data/located_compound.brep create mode 100644 tests/data/manifest.json create mode 100644 tests/test_bintools_gate.py create mode 100644 tests/test_forksafety.py create mode 100644 tests/test_handles.py create mode 100644 tests/test_import.py create mode 100644 tests/test_shape_identity.py create mode 100644 tools/gen_fixtures.py create mode 100644 tools/inventory.py create mode 100755 tools/parity_venv.sh diff --git a/.gitignore b/.gitignore index 9f4ee1c..4cea0df 100644 --- a/.gitignore +++ b/.gitignore @@ -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) diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..aeaca3c --- /dev/null +++ b/CMakeLists.txt @@ -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 .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) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a705f74 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 70276d2..bc6eba7 100644 --- a/README.md +++ b/README.md @@ -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 `.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 `.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. diff --git a/docs/adding-symbols.md b/docs/adding-symbols.md new file mode 100644 index 0000000..bc75d7a --- /dev/null +++ b/docs/adding-symbols.md @@ -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_.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 + +void register_Some(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "Some"); + + nb::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()`. + 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.")` and re-export them in +`python/n3xd_ocp/__init__.py`. diff --git a/docs/building.md b/docs/building.md new file mode 100644 index 0000000..fee04f4 --- /dev/null +++ b/docs/building.md @@ -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. diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..b6ff2da --- /dev/null +++ b/docs/design.md @@ -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` 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()` (`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 `.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. diff --git a/inventory.json b/inventory.json new file mode 100644 index 0000000..bcbdcbd --- /dev/null +++ b/inventory.json @@ -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": [] + } + } +} diff --git a/occt/Dockerfile b/occt/Dockerfile new file mode 100644 index 0000000..ca2b582 --- /dev/null +++ b/occt/Dockerfile @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fe808d3 --- /dev/null +++ b/pyproject.toml @@ -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" +# .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"] diff --git a/python/OCP/__init__.py b/python/OCP/__init__.py new file mode 100644 index 0000000..0469ab2 --- /dev/null +++ b/python/OCP/__init__.py @@ -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__"] diff --git a/python/OCP/py.typed b/python/OCP/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/python/n3xd_ocp/__init__.py b/python/n3xd_ocp/__init__.py new file mode 100644 index 0000000..d6a3ade --- /dev/null +++ b/python/n3xd_ocp/__init__.py @@ -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"] diff --git a/python/n3xd_ocp/py.typed b/python/n3xd_ocp/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/scripts/_env.sh b/scripts/_env.sh new file mode 100755 index 0000000..4d6f097 --- /dev/null +++ b/scripts/_env.sh @@ -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 diff --git a/scripts/asan.sh b/scripts/asan.sh new file mode 100755 index 0000000..2ac69d1 --- /dev/null +++ b/scripts/asan.sh @@ -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" diff --git a/scripts/build_wheel.sh b/scripts/build_wheel.sh new file mode 100755 index 0000000..905d416 --- /dev/null +++ b/scripts/build_wheel.sh @@ -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/ diff --git a/scripts/in_container_test.sh b/scripts/in_container_test.sh new file mode 100755 index 0000000..a32bb00 --- /dev/null +++ b/scripts/in_container_test.sh @@ -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 "$@" diff --git a/scripts/stubgen.sh b/scripts/stubgen.sh new file mode 100755 index 0000000..79302e8 --- /dev/null +++ b/scripts/stubgen.sh @@ -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 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 diff --git a/src/common/occt_exceptions.cpp b/src/common/occt_exceptions.cpp new file mode 100644 index 0000000..eaf9823 --- /dev/null +++ b/src/common/occt_exceptions.cpp @@ -0,0 +1,109 @@ +#include "occt_exceptions.h" +#include "occt_module.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +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 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); +} diff --git a/src/common/occt_exceptions.h b/src/common/occt_exceptions.h new file mode 100644 index 0000000..41956a2 --- /dev/null +++ b/src/common/occt_exceptions.h @@ -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 + +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); diff --git a/src/common/occt_handle.h b/src/common/occt_handle.h new file mode 100644 index 0000000..70234dd --- /dev/null +++ b/src/common/occt_handle.h @@ -0,0 +1,124 @@ +/* + Type caster for opencascade::handle — 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 + +#include +#include + +#include + +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 &&h, + PyObject *o) noexcept { + keep_alive(o, new opencascade::handle(std::move(h)), + [](void *p) noexcept { + delete (opencascade::handle *) p; + }); +} + +template +struct type_caster< + opencascade::handle, + enable_if_t>>> { + static constexpr bool IsClass = true; + using Caster = make_caster; + using Td = std::decay_t; + + NB_TYPE_CASTER(opencascade::handle, Caster::Name) + + static_assert(is_base_caster_v, + "Conversion of opencascade::handle 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(ptr), result.ptr()); + + return result; + } +}; + +NAMESPACE_END(detail) +NAMESPACE_END(NB_NAMESPACE) diff --git a/src/common/occt_module.cpp b/src/common/occt_module.cpp new file mode 100644 index 0000000..160bfba --- /dev/null +++ b/src/common/occt_module.cpp @@ -0,0 +1,28 @@ +#include "occt_module.h" + +#include + +std::vector &ocp_module_registry() { + static std::vector 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(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 ® = ocp_module_registry(); + if (std::find(reg.begin(), reg.end(), name) == reg.end()) + reg.emplace_back(name); + return m; +} diff --git a/src/common/occt_module.h b/src/common/occt_module.h new file mode 100644 index 0000000..a3d1834 --- /dev/null +++ b/src/common/occt_module.h @@ -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 + +// 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 +#include + +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 &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.", 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__) diff --git a/src/common/occt_policies.h b/src/common/occt_policies.h new file mode 100644 index 0000000..3c6cebb --- /dev/null +++ b/src/common/occt_policies.h @@ -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 + +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() diff --git a/src/common/occt_stream.h b/src/common/occt_stream.h new file mode 100644 index 0000000..f0cf14d --- /dev/null +++ b/src/common/occt_stream.h @@ -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 + +#include + +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); +} diff --git a/src/common/occt_transient.h b/src/common/occt_transient.h new file mode 100644 index 0000000..b898732 --- /dev/null +++ b/src/common/occt_transient.h @@ -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 + +#include +#include + +#include + +namespace nb = nanobind; + +/// Declare a bound transient class. Bind its constructors with ocp_new() — nb::init<> on such a class is a bug (see above). +template +nb::class_ ocp_transient_class(nb::handle scope, const char *name) { + return nb::class_(scope, name); +} + +/// Heap-allocating constructor for a transient class: +/// +/// cls.def(ocp_new(), +/// "nbNodes"_a, "nbTriangles"_a, "hasUVNodes"_a); +template auto ocp_new() { + return nb::new_([](Args... args) { + return opencascade::handle(new T(std::forward(args)...)); + }); +} diff --git a/src/core.cpp b/src/core.cpp new file mode 100644 index 0000000..5ef228c --- /dev/null +++ b/src/core.cpp @@ -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 +#include +#include + +#include "common/occt_exceptions.h" +#include "common/occt_module.h" + +#include + +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; +} diff --git a/src/ext/ext_bintools.cpp b/src/ext/ext_bintools.cpp new file mode 100644 index 0000000..8cdab60 --- /dev/null +++ b/src/ext/ext_bintools.cpp @@ -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 + +#include +#include + +#include + +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."); +} diff --git a/src/ext/ext_core.cpp b/src/ext/ext_core.cpp new file mode 100644 index 0000000..eb6b843 --- /dev/null +++ b/src/ext/ext_core.cpp @@ -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(); +} diff --git a/src/ext/ext_debug.cpp b/src/ext/ext_debug.cpp new file mode 100644 index 0000000..c46fe54 --- /dev/null +++ b/src/ext/ext_debug.cpp @@ -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 +#include + +#include + +namespace { + +/// Holds handles alive on the C++ side so a test can drop every Python +/// reference and check the object survived. +std::vector> &cpp_holds() { + static std::vector> held; + return held; +} + +} // namespace + +void register_ext_debug() { + nb::module_ m = ocp_named_module("n3xd_ocp._debug"); + + m.def( + "refcount", + [](const opencascade::handle &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 &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 &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 tri = + opencascade::handle::DownCast( + held[(size_t) slot]); + return tri.IsNull() ? -1 : tri->NbNodes(); + }, + "slot"_a, "Touch a held object's memory — catches a premature free."); +} diff --git a/src/modules/mod_BRep.cpp b/src/modules/mod_BRep.cpp new file mode 100644 index 0000000..94064be --- /dev/null +++ b/src/modules/mod_BRep.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +void register_BRep(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "BRep"); + + nb::class_(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_ 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); +} diff --git a/src/modules/mod_BinTools.cpp b/src/modules/mod_BinTools.cpp new file mode 100644 index 0000000..2f8d2ae --- /dev/null +++ b/src/modules/mod_BinTools.cpp @@ -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/.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 +#include + +#include + +void register_BinTools(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "BinTools"); + + nb::class_ 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); +} diff --git a/src/modules/mod_Poly.cpp b/src/modules/mod_Poly.cpp new file mode 100644 index 0000000..e1b71c6 --- /dev/null +++ b/src/modules/mod_Poly.cpp @@ -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 + +void register_Poly(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "Poly"); + + nb::class_(m, "Poly_Triangle") + .def(nb::init<>()) + .def(nb::init(), + "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_(m, "Poly_Triangulation") + .def(ocp_new()) + .def(ocp_new(), + "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(&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_)); +} diff --git a/src/modules/mod_Standard.cpp b/src/modules/mod_Standard.cpp new file mode 100644 index 0000000..c0413f9 --- /dev/null +++ b/src/modules/mod_Standard.cpp @@ -0,0 +1,25 @@ +#include "../common/occt_module.h" + +#include +#include +#include + +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_(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; +} diff --git a/src/modules/mod_TopAbs.cpp b/src/modules/mod_TopAbs.cpp new file mode 100644 index 0000000..21df840 --- /dev/null +++ b/src/modules/mod_TopAbs.cpp @@ -0,0 +1,44 @@ +#include "../common/occt_module.h" + +#include +#include +#include +#include + +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_(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_(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_(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_ 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); +} diff --git a/src/modules/mod_TopExp.cpp b/src/modules/mod_TopExp.cpp new file mode 100644 index 0000000..f6ef2d0 --- /dev/null +++ b/src/modules/mod_TopExp.cpp @@ -0,0 +1,48 @@ +#include "../common/occt_module.h" +#include "../common/occt_policies.h" + +#include +#include +#include +#include + +void register_TopExp(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "TopExp"); + + nb::class_(m, "TopExp_Explorer") + .def(nb::init<>()) + .def(nb::init(), + "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_ 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); +} diff --git a/src/modules/mod_TopLoc.cpp b/src/modules/mod_TopLoc.cpp new file mode 100644 index 0000000..efeebc8 --- /dev/null +++ b/src/modules/mod_TopLoc.cpp @@ -0,0 +1,24 @@ +#include "../common/occt_module.h" +#include "../common/occt_policies.h" + +#include + +void register_TopLoc(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "TopLoc"); + + nb::class_(m, "TopLoc_Location") + .def(nb::init<>()) + .def(nb::init(), "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); +} diff --git a/src/modules/mod_TopTools.cpp b/src/modules/mod_TopTools.cpp new file mode 100644 index 0000000..771e2fa --- /dev/null +++ b/src/modules/mod_TopTools.cpp @@ -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 + +#include +#include +#include + +void register_TopTools(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "TopTools"); + + nb::class_(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::type(), "ListOfShapeIterator", + self.begin(), self.end()); + }, + nb::keep_alive<0, 1>()); + + nb::class_(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_( + 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( + &TopTools_IndexedDataMapOfShapeListOfShape::FindFromKey, + nb::const_), + "theKey"_a, OCP_RETURN_COPY) + .def("FindFromIndex", + nb::overload_cast( + &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); +} diff --git a/src/modules/mod_TopoDS.cpp b/src/modules/mod_TopoDS.cpp new file mode 100644 index 0000000..6d8bea9 --- /dev/null +++ b/src/modules/mod_TopoDS.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +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_(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{}(s); + }); + +#define OCP_SHAPE_SUBCLASS(Type) \ + nb::class_(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_(m, "TopoDS_Iterator") + .def(nb::init<>()) + .def(nb::init(), + "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_ 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 +} diff --git a/src/modules/mod_gp.cpp b/src/modules/mod_gp.cpp new file mode 100644 index 0000000..7bde405 --- /dev/null +++ b/src/modules/mod_gp.cpp @@ -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 +#include +#include +#include +#include +#include + +void register_gp(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "gp"); + + nb::enum_(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_(m, "gp_XYZ") + .def(nb::init<>()) + .def(nb::init(), "X"_a, + "Y"_a, "Z"_a) + .def("X", &gp_XYZ::X) + .def("Y", &gp_XYZ::Y) + .def("Z", &gp_XYZ::Z); + + nb::class_(m, "gp_Pnt") + .def(nb::init<>()) + .def(nb::init(), "Xp"_a, + "Yp"_a, "Zp"_a) + .def(nb::init(), "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(&gp_Pnt::Translate), "V"_a) + .def("Translated", + nb::overload_cast(&gp_Pnt::Translated, nb::const_), + "V"_a, OCP_RETURN_COPY); + + nb::class_(m, "gp_Vec") + .def(nb::init<>()) + .def(nb::init(), "Xv"_a, + "Yv"_a, "Zv"_a) + .def(nb::init(), "V"_a) + .def(nb::init(), "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_(m, "gp_Dir") + .def(nb::init<>()) + .def(nb::init(), "Xv"_a, + "Yv"_a, "Zv"_a) + .def(nb::init(), "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_(m, "gp_Ax1") + .def(nb::init<>()) + .def(nb::init(), "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_(m, "gp_Trsf") + .def(nb::init<>()) + .def("SetTranslation", + nb::overload_cast(&gp_Trsf::SetTranslation), + "V"_a) + .def("SetTranslation", + nb::overload_cast( + &gp_Trsf::SetTranslation), + "P1"_a, "P2"_a) + .def("SetRotation", + nb::overload_cast( + &gp_Trsf::SetRotation), + "A1"_a, "Ang"_a) + .def("SetScale", &gp_Trsf::SetScale, "P"_a, "S"_a) + .def("SetMirror", nb::overload_cast(&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); +} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1bb5b55 --- /dev/null +++ b/tests/conftest.py @@ -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) diff --git a/tests/data/box.brep b/tests/data/box.brep new file mode 100644 index 0000000000000000000000000000000000000000..7b5d78e60e0ba4a037eae0c1279aab32e8922bdd GIT binary patch literal 4494 zcmcIl+fEcg5S?B?biCj1IvO5y;)BA*M;HPoKA2zv@qM$bNJ4f9D<=4~-{DXA119(v z{>H{Huxh7IO;1;MW_hp!>7K6Ys#B*<_oiEigJHI@zP+)&`7YZj56gpc?0zxQ>&9FDTr>BjNVx54u6C|mBPCJBzo8n$P3SS@g(IZsX_5#Tmi z^BlUrE7RFAAf!K^9RngnF)S68i*jh?0Vk0H^VxYoB!vMX{aJXxN#)@8VrB=0A<9E6 zg^J+k#KRMg%*wggKvM1W)R15wNe>k zZI^>~AsPGwtu^4l%r)SE4JE{idXg6v{yD|2j6&yqjtq1%XWK#+M0Aj2$Xi(CV;6~wMWG>}BD7QSEIWTmilXZuU< zkQBDkO&8z){PpASpYN+)IQz+BHi@CXyv36bSkH5a$>+QVN!$(_qQ(vx`Cku_d~DtF20P*NM8G1sN7*?~Muw(`!wwQA(!qtQ_{3!Ppm|~!$s;Tt z+C@H}h|=Iqf}28m03T1b{xx$X(c;MgLyZX+N!(wc1I)i3WK3x{EIX$$_i*&CUHIZb zHEjY9!=M&kq`*<*B{GI(=d|!PFam5FM)nW!$ZFDqsJcwMVT@$FLdLK_9UcYaO`D)8 zaF(hb#LiXP4QD3fH8O^K@34b}iF8l|e#fRXw~+e=7T4{<5jbElb5ntHdLGSESaW(T z6&(&4)*Bf)ak!MFV%jXS8&pu=)y6Bqg4TnAGX>GiTzgnzPG%@8kvCLGAT%S519%zHe2^5{6VNO7hCIO-KIv{=ni~y5MgMK>!!G#0_ ip7=*)%XXfS1bkq literal 0 HcmV?d00001 diff --git a/tests/data/box_meshed.brep b/tests/data/box_meshed.brep new file mode 100644 index 0000000000000000000000000000000000000000..ede67f6051673f5c79bf0ed7962b8e143956f4ee GIT binary patch literal 6931 zcmbtX&5jf|5bj>UBtt?Vguf7=L3<#h9LUn*xC~m5I3R(vEAFFZ*&wZERy%AYa+=o& zFOhSkNbnYUja8l?Ro(Tu?Xo@Y1`AZS>#NV7%VqcM=*8jQtlE0Kz4iFX(`sjaI6s*0 zzpq}^zf|{jf38~Zbg?^qy*GM3-<`gDJD)A8`=hPnqu=*7UN5SRdQ=wCcJc_*eF2*j zMELgPV}b#&lSc|re`;OBHGopv;Tph@)UZBjApszp2Sj2C*x`8qQ=kEq+S)uIG63ka z=nV)oB=eA3B4E&KV;m-G(z*hM-WuwFp}8ZA>er3b4S^bM19bpcG0S!)PzxZ$V61I& z1q)oZyXy$1wn3B|IV3HA^GWugPh#8N#g2*e(;0eTO;1JWzJ zoFBa3pU)mVp;uV?FJ?POZ>O{U;{$o0=}}%It*oByhd;Uft#8fd1fWwn5Cc!ALA7OC zv0Mw{rB*Nv(8Rl2A$)+QUU|x-pcS40^-4i6P_N~BC9~IZy^^_Zt6;^kw4h8C3}DKf zUCWd?mI0cwOxwptZ>GC@i|S$S)eqkSD(FblxRZ|n@!nUpW$V-gIs|@L>(4!Z7APGM zz*_%OdSW}j+JSy9!x!9AdL0nLT4!np7N9yHfVJ+W^u%_4*+J?X)B$}08Cj!o-65-| z{o#mTPcB21J4==8;L`$JkaTfiUgHs$n#XT%`0Gzl%eS8Uxcx(Sl1efZ!D3~N$2@S_ z0(u>W;yLIh|uDfFF3wYdmn;0*5&GF|I#*Ctwa4G2>g{KX)fk zjCinP{N-l?Mp!7u7z!|k#k|JDuPtzhgCFDJXM#vD!Rq-gS1kfcG#>N7!w;L9?|cjP0;(67b}aB{*5z8juWH-C&$GfNcvI4j-->XG5-(S z=ES&>#z)eE6jI(r+#Ttp)Vs-!sgG-*A7cF8#MP6NA0-Bat)3V+()dVGe;aYwMovBB zW9s7?DEDWY04(ELavB=5uCP(=-ALmjwfdVvF!t&p&P+)=OYmeyzal3u9X86S8)N!~S(UX_k6N9;3qnc@QJ=+6;x()kTJHU^wqzmPE3H6#Sy3+zDYDi4-~lP*k5BOoeFMwuI0t?{xRthO@NSmS RXHQ_fhF^y$#xe1=?SC1r#Cre$ literal 0 HcmV?d00001 diff --git a/tests/data/cut_cylinder.brep b/tests/data/cut_cylinder.brep new file mode 100644 index 0000000000000000000000000000000000000000..db509bc4c95ed2edb53dd4948393f1d7b76016d9 GIT binary patch literal 6058 zcmc&%&2AJ&5bjxnaW+4Y#3Vp|7{CzNfRw=Iz{MV9N6H}*k!03EqO&Sml~SQZ-#)^-OiQ#*UCu0#|QWb=6nDUJoB1?oG?hN86i^wtg&kW{0zb z+5U_2*YVx*_U@h1ODFT)$&O1 zkq7Xon!pmm96-0rgM!@Gp5uO{l83967XShj{4M>g{R#Igm1!_lltY9$n-;w6ZP6H8 zABIyW7FZCbbN@qPFWuYP`TN@CO2E_r<$07?lm=>xjb<0k%kO{4Z6c^z9@`cTG#q?| z{FoA;%GNSIXFWin*Mlm`AwZFfS|Fia>;wBb3kgAIJ-`xz?tNe(;Xxd9W-RpqTnh(8 za23~b5^m+u7$X!Iun(Ngu6=-q;F?_4Po$g)isF)19N|lj;8PfqoU(K0bOn+1;C$5BuiNLP9RYp)#wtcny&m zKU^!X{mB|oxf~z|S2@b97jk%ajc6i>`l5L^{%ISCK)Xc&h( z+s`J4G>qf%@Z3+Y{(Sk@+dtOTPUpa37OtRwqaICuCYsBz$?r@ri}m$tz=Euh%bWh1 zA&B_#T`}gql|v3A2B;NH2V>gPxEd_68+hq(5}u(8R)~rHQRpNn?m!B#eHd9kmB+Lv z%H!GQ0o=4X376?J++LntO6IK0vy3GndAYnRyR!fwy3$Wdm;L68D3HXq1)5b~o5ed_6%n{4Ll%&*< z4ve0=?0o~GS3Bcg$HZAd$DBB4{y8E^ScXUh?*Lk{44+#k`kgzxCFJKp= z*iAS0Tud~9AJc8R&?NkXglW&M2~^LeI2dWVF?hul5ruSLhO$mq`fFpZb9qkMgg@^F zpVF;uE)}kl&=x_37fEQNg%Hi6LW1?BlFO~gRB*#K zq&;;@E?RkqC(c}xcb*}pZYaEKVxq=p+v!k9$SBiEY)u6saJv3zPCCB@X zDRf18Dp)7g6?o5bCbaM1OZt$V3SMh=LFh|fkTzNfbt=#k1rDKbXw+79V2&d2Q=xUh zREOtS)CCrX(*{7WEz78x)*Zv%tvj#VnAWQ$_EysWWt%In-D*{gzl$Tr9ta(T92CIU zf(%az8QB>v`4YS0hWG9Yu3|&H#~MN;h1O5)VFpG6pX9LIM!ZiN!f!ab@fYr2fg!7D literal 0 HcmV?d00001 diff --git a/tests/data/empty_compound.brep b/tests/data/empty_compound.brep new file mode 100644 index 0000000000000000000000000000000000000000..670c282bf6563f4a3b1d9be1f6ebce87dd2ae16f GIT binary patch literal 171 zcmd<`FG$T(aCQuKc64!72+1$V&&f}(R0uQCQP4=%RDj4P7AGgBq;mP>CnuI<=I0eF z7;rh47L}zMrNEgWMnHZ}WqN*|u?vFjpBGY;nV6SengiDvTw0Wtn4AjIfFZ{f5}c7( U0F*Q2Vqj!s00Auq2+hC%084i=A^-pY literal 0 HcmV?d00001 diff --git a/tests/data/fused.brep b/tests/data/fused.brep new file mode 100644 index 0000000000000000000000000000000000000000..9164dc5afe414d99c41c0803cc57166c8809075b GIT binary patch literal 10602 zcmcIp>vB{@5T03NS^D4ZGyV14&UiMI4v?OmzJ1-((=%BHH}7w457#cQuU)=!b$DxXe{yef z=kf5n@u}hQ%@ae%-PqaOxV<&FG1=Vs@$O`MXLx$B_VB@@t)<&L!*k~c*0hejRV+^{ zaH)buO!uA?EI@2;H4zx^I@bO%z+_7Ik0G;=f`q`+gIuVbjm$zZlT%}u+QN9>7_gf_ zk34|xe>Y*t=iwP=tS{h>R8k;wvH*tnS<>k5I+;b$OnubLBGBIzAFvHydrzDNW1No` z4KcAOR5j`nstc8~319A$F?*{ejdC2x*?}q0d^;+~@|oze(7of5nKQf2wB}Od6ozB_ zIO|LUSx3C8@g&Yny^NYb4(G&&kE^I5lJJ#k>;skT*FH3mOazwF|DjWjE9b<=Iqi-^ zGXq&_L=eoX&NDC*3`G~6iApg%{jE{Nkh!QF`Ye-03y7j902iGC$Qx*e0SwTh09=&? zcAbr~K$QwKFN&Sv(uLA{!YSlGiNM13?5}*MR6!i^Y5(NYgMJRx3S^)`yP*g#Emxu=lq_h|V z1c4o&eX!yS8K$3IQm979l|(RqTY6DopJGiog!4 zF#r;1S>fBsy~lSZ+h?!PFMTq8W zsa$p*V)5Wp?dGY0K%$v~52}0=t1iey8(+eJ0Z?VvG85|ga^4^~t}qi?JL(k@zI+59 zRHaH0s6aA-0Z?VvG85|gNl#J*nRvn2K$l#$$tcW}mJAgg*G|LpJ9?fM6jx$EJ}F7% zIwQc5QwuVIAyDPlG85|g9TDX1MNLK%7z9-&stCCUa(w>J9HfTfAV;LwqDd;}_aJLj zgInuAY}}_twX!mpz5d(pzyA5hPnRNN`hkzTHJ1L4IwV;|Bxhig^^#*QmM>L3W)zt{ zV)DOy2qI$GZ@|4FlM=>p9u1dW}lcWkPr4pp%cS?1unpLVPyHX zJhpqHJRWQm;3lNRoT1N*Gao@772|ag8%5b^*QW%`l6WyY{Ivl?%SVtMI5fJ02VL;S zel3djOIY{!_BXBmjeVw`uWgbe;@rtb}HSNxk@FY2F0f?A9GdjRa$LN4>9M6*}iMyhfS{V4;psa zQ8P*~vMi5r)es}P6wqh}(eO3MN79de<>p7K-fJ|AVFb*1V#a69GjU?f*x8yMBR@4H z;}J&g3_5}@=zFizYJ@RlzCq0BXar2fxFmKhz@~XzsGJ1ySi;B+#t)y3-kY=!p$TM?{r^P{h2;(ZlbB(%fUS$C@uQJb6&v0mQ8e`AgH}gTgwt-%jaJt&aZoyYz{s=>Bi}ZD^s8o0|Ao{$O0x(f z&K@)0BW8SdyryFOps__C6=T$y_$t#8#MpbERwK_LbAgyq_Y9cod0FgQK=37@*B#2- zeh3h3x)}_h8G2k$HntK@P&R-{*`+@;I5|AhOA=dwW)W_QzIn+8_Z~kMD>tX(z|llq z*#t}uBT7ub)Wo1u+aXEwNHGE%XbB^?z%uLnZRiI&^Kc!w1g4) zEMeqZI3EOq&vkS!suVGKtEb1v4Vg#W+~?h*`~*%iev+2ra}zMRbH<)M6WKASXse#} zZO2`OhvlByjKi4lUD*sRZCn6BA;Y&8GGD(u(hUGC6bVyOqQ7w(_=(|1P^)~86bqRT zX4L=M3R&MC@DY3F_e(x#1NfGUJwuF`+*C`oiw&@FXojNcd&@j*Y2{%{46fNA-GHI# z#+gdf9j2K*l+umu0t!bdE_PmutH+s)(+&M`r9o@2M)W0&VQI9Lgcc&4G?XxMJr>U- zx2@?h;@XW%;&9{B0`0(|P&Gf$rX-v3FgHYf}L%~8EiZ(A` zX()xlzk3!#;cLBA;u?xpF|HUN3cOmJN~0Tw!yJSHPh8>6m5ZYCWaqxJS-Zae^jU|l$L*M^_`L#_1r_of7LMJ N?%oW114An!{SP5G)XV?? literal 0 HcmV?d00001 diff --git a/tests/data/located_compound.brep b/tests/data/located_compound.brep new file mode 100644 index 0000000000000000000000000000000000000000..11b74d4bf0a87a3d0f9e3e75d2c443cfe098e980 GIT binary patch literal 4618 zcmcIm+fEcg5ba(-biCj1Iw}u3@d07=5r%+?CMK9beBUf9l8_z3iU~gLclZJE zzp?QPteWXl)6H6B%`r5|Zbh|hx_KV%)^i%g``n>-l zt+c&Szqd0;J{0}l*S%smN?#>fl`>haR`>IL-aLufHLaAY?beSDzYUglM(I))$>PQa zO{>95?wVBJCnu2zFrTbC2HoGrQwVSjYESGU22h_KgLuX;Eaa6*&8orB$^$%+0_*8{ zKqL6nu@qheH#fffMCFyri!e2Yt{0)aBXk>=VmAb6+|@$| z0|7IxPCT?Q1Q>jG*JuVY99KIpL#*xMXf~3;b!e>t4pY|v2N!}GS0`Q#o}oFY4b8zg z@+hE&RtL9+sU6}OJ{J4OyTx#MBVA0)esj2exYrx*9_^bSfW>6%=D-W|)n*L=imh50G z&mboIISrDy9acn@9a8dtUkoBr=x5MAHJcblOodfV17kQ-H}A}F8f0m#q@1M#Ht>b> zQR$@6DSeazux%LGIK*Q((@Nf%D;>%l&Ic*a&=KSG#o{rA_L13S4R*r*M8G_mN7*?` zN`~f(#SRiG>EK*dywXpMqkfDC53zY*HrcF_NrN>FTnXtuY@D%mubDLxEtVWG)R=IQ z#QiyPVE%m}WkRRnv9lU;7p`~A#vLA1(>CB?7}Ub^6gaB9K+5pgSuMN;i~!q)k?lh~ z@-+EER9&RgFh){dB4v0$Egl8q4U?cb;5@3n5IdLYG@O}~S4bJ|y~Pd^D(RpM_-&I? z-$d@~*jzIkYrp}6nJWkUDr_2em1oYEg}lS&;dvt^=L?szkeAIOy+#G~U2SCMf%b)h zb5+sI%ujg4tjw4!C2y#Z$Q@$QU{moxl!6DuA`vizL>?`c<~;DF@P$g@PKAe!lssgu zB+5MC.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") diff --git a/tests/test_forksafety.py b/tests/test_forksafety.py new file mode 100644 index 0000000..b95ebdb --- /dev/null +++ b/tests/test_forksafety.py @@ -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 diff --git a/tests/test_handles.py b/tests/test_handles.py new file mode 100644 index 0000000..fdd2c5e --- /dev/null +++ b/tests/test_handles.py @@ -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 diff --git a/tests/test_import.py b/tests/test_import.py new file mode 100644 index 0000000..4d065e1 --- /dev/null +++ b/tests/test_import.py @@ -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" diff --git a/tests/test_shape_identity.py b/tests/test_shape_identity.py new file mode 100644 index 0000000..dbe4626 --- /dev/null +++ b/tests/test_shape_identity.py @@ -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 diff --git a/tools/gen_fixtures.py b/tools/gen_fixtures.py new file mode 100644 index 0000000..1cab86a --- /dev/null +++ b/tools/gen_fixtures.py @@ -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/.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()) diff --git a/tools/inventory.py b/tools/inventory.py new file mode 100644 index 0000000..f393200 --- /dev/null +++ b/tools/inventory.py @@ -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. import ` + 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" ({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()) diff --git a/tools/parity_venv.sh b/tools/parity_venv.sh new file mode 100755 index 0000000..ab98a4b --- /dev/null +++ b/tools/parity_venv.sh @@ -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)"