Phase 10A/10B Inc 0: build system, handle model, first module surface
Builds n3xd-ocp end to end and publishes 7.9.3.1.dev1 to the Gitea registry, where it installs anonymously and passes its suite. - occt/Dockerfile: OCCT 7.9.3 compiled once into a manylinux_2_28 builder image (base digest + tarball sha256 pinned), Draw/VTK/Tk/Xlib/OpenGL off, FreeType on, -O2 without fast-math or march=native. A final layer asserts TKService/TKV3d exist with no libGL/libX11 DT_NEEDED, which is what lets the app image drop libgl1/libx11-6. Mounted into, never built FROM. - scikit-build-core + nanobind STABLE_ABI -> one cp312-abi3 extension that registers every OCP.* submodule via PyImport_AddModule, so `import OCP.TopoDS` needs no shim and cls.__module__ is right. Version <occt>.N is asserted against the OCCT found, keeping occt_version() truthful. - occt_handle.h: type caster for opencascade::handle<T> over OCCT's intrusive refcount. Wrappers are non-owning instances holding exactly one handle in their keep-alive list, reusing an existing wrapper so identity survives a round trip. Transient constructors go through ocp_new (never nb::init<>, which would let OCCT delete nanobind's storage); the caster refuses a refcount-0 object rather than corrupt the heap. Verified under ASAN with no memory-safety errors, plus an RSS bound over 50k create/destroy cycles. - Sub-shapes are returned by value everywhere, making the TShape lifetime class that segfaulted a process-global face memo unrepresentable. - Standard_Failure derives RuntimeError, with ~20 concrete types dispatched on the dynamic OCCT type (cad_pool marshals failures home by type name). - Inc 0 surface: gp subset, TopAbs, TopoDS (+ downcasts), TopExp, TopLoc, TopTools, BRep, BinTools, Poly, Standard. 34 of the app's 139 symbols. - n3xd_ocp: additive APIs kept out of the OCP namespace so parity testing stays meaningful. bintools (shape <-> bytes, GIL-free, byte-identical) and _debug. Two findings worth the record, both verified against the stock wheel rather than assumed: upstream binds __hash__ but leaves __eq__ at identity, which is exactly what geom_memo.py's hash-bucket + IsSame scan is built around, so we match it instead of "fixing" it; and BinTools can release the GIL after all, by slurping the file-like object instead of bridging a streambuf that would call back into Python. Gate: BREP round-trips are byte-identical to cadquery-ocp-novtk across six fixtures (the generator asserts stock idempotency first). That matters beyond IPC — derive.py content-addresses BREP payloads by sha256 and stores the ref.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,6 +22,7 @@ CMakeUserPresets.json
|
||||
*.dylib
|
||||
*.pyd
|
||||
python/OCP/*.pyi
|
||||
python/n3xd_ocp/*.pyi
|
||||
|
||||
# Vendored or locally built OCCT (never committed; it comes from the
|
||||
# builder image, and a kernel tree would dwarf this repo)
|
||||
|
||||
64
CMakeLists.txt
Normal file
64
CMakeLists.txt
Normal file
@@ -0,0 +1,64 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(n3xd_ocp LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
if(NOT CMAKE_BUILD_TYPE)
|
||||
set(CMAKE_BUILD_TYPE Release)
|
||||
endif()
|
||||
|
||||
# Match the kernel's flags: -O2, no fast-math, no -march=native. OCCT's
|
||||
# version is a determinism input for assay's committed goldens, so the binding
|
||||
# has no business introducing a different FP contract than the kernel it wraps.
|
||||
set(CMAKE_CXX_FLAGS_RELEASE "-O2 -DNDEBUG")
|
||||
|
||||
find_package(Python 3.12 REQUIRED
|
||||
COMPONENTS Interpreter Development.Module Development.SABIModule)
|
||||
find_package(nanobind CONFIG REQUIRED)
|
||||
find_package(OpenCASCADE REQUIRED)
|
||||
|
||||
# The version scheme is enforced, not merely conventional: a wheel labelled
|
||||
# 7.9.3.N must actually wrap OCCT 7.9.3.
|
||||
set(_occt_ver "${OpenCASCADE_MAJOR_VERSION}.${OpenCASCADE_MINOR_VERSION}.${OpenCASCADE_MAINTENANCE_VERSION}")
|
||||
if(DEFINED SKBUILD_PROJECT_VERSION)
|
||||
if(NOT SKBUILD_PROJECT_VERSION MATCHES "^${_occt_ver}\\.")
|
||||
message(FATAL_ERROR
|
||||
"Version '${SKBUILD_PROJECT_VERSION}' does not match the OCCT found "
|
||||
"(${_occt_ver}). The scheme is <occt-version>.N — see pyproject.toml.")
|
||||
endif()
|
||||
endif()
|
||||
message(STATUS "Building n3xd-ocp against OCCT ${_occt_ver}")
|
||||
|
||||
file(GLOB OCP_MODULE_SOURCES CONFIGURE_DEPENDS "src/modules/*.cpp")
|
||||
file(GLOB OCP_EXT_SOURCES CONFIGURE_DEPENDS "src/ext/*.cpp")
|
||||
|
||||
nanobind_add_module(_OCP
|
||||
STABLE_ABI
|
||||
NB_STATIC
|
||||
src/core.cpp
|
||||
src/common/occt_module.cpp
|
||||
src/common/occt_exceptions.cpp
|
||||
${OCP_MODULE_SOURCES}
|
||||
${OCP_EXT_SOURCES}
|
||||
)
|
||||
|
||||
target_include_directories(_OCP PRIVATE ${OpenCASCADE_INCLUDE_DIR})
|
||||
|
||||
# Only the toolkits the bound surface actually reaches. Grows with each
|
||||
# coverage increment; auditwheel bundles whatever the linker records.
|
||||
target_link_libraries(_OCP PRIVATE
|
||||
TKernel
|
||||
TKMath
|
||||
TKG2d
|
||||
TKG3d
|
||||
TKGeomBase
|
||||
TKBRep
|
||||
TKTopAlgo
|
||||
)
|
||||
|
||||
target_compile_definitions(_OCP PRIVATE
|
||||
N3XD_OCP_VERSION="${SKBUILD_PROJECT_VERSION}"
|
||||
)
|
||||
|
||||
install(TARGETS _OCP LIBRARY DESTINATION OCP)
|
||||
56
Makefile
Normal file
56
Makefile
Normal file
@@ -0,0 +1,56 @@
|
||||
OCCT_VER ?= 7.9.3
|
||||
IMG_N ?= 1
|
||||
IMAGE ?= git.stroblme.de/n3xd/occt-build:$(OCCT_VER)-$(IMG_N)
|
||||
# Build cache lives off the root filesystem, which is tight on this host.
|
||||
CACHE ?= /mnt/cache/n3xd/ocp
|
||||
REPO := $(shell pwd)
|
||||
PUBLISH_URL ?= https://git.stroblme.de/api/packages/N3XD/pypi
|
||||
|
||||
# The builder image is a compiler appliance: mounted into, never built FROM, so
|
||||
# iterating on the binding never re-layers the kernel. $(CACHE) keeps the
|
||||
# CMake tree and ccache between runs, which is what makes the inner loop a
|
||||
# recompile of one TU rather than a full build.
|
||||
DOCKER_RUN = docker run --rm -u $(shell id -u):$(shell id -g) \
|
||||
-v $(REPO):/io -v $(CACHE):/cache -e CACHE_ROOT=/cache $(IMAGE)
|
||||
|
||||
.PHONY: help image image-push wheel dev shell test test-asan stubs publish clean clean-cache version parity
|
||||
|
||||
help:
|
||||
@grep -E '^[a-z-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " %-14s %s\n", $$1, $$2}'
|
||||
|
||||
image: ## Build the OCCT builder image (~40 min; only when occt/Dockerfile changes)
|
||||
docker build -f occt/Dockerfile -t $(IMAGE) occt/
|
||||
|
||||
image-push: ## Push the builder image (needs: docker login git.stroblme.de)
|
||||
docker push $(IMAGE)
|
||||
|
||||
wheel: ## Build + repair + smoke-test the wheel into wheelhouse/
|
||||
$(DOCKER_RUN) bash /io/scripts/build_wheel.sh
|
||||
|
||||
dev: ## Incremental build + run the test suite (the inner loop)
|
||||
$(DOCKER_RUN) bash /io/scripts/in_container_test.sh
|
||||
|
||||
test: dev ## Alias for dev
|
||||
|
||||
shell: ## Interactive shell in the builder image
|
||||
docker run --rm -it -v $(REPO):/io -v $(CACHE):/cache $(IMAGE) bash
|
||||
|
||||
stubs: ## Regenerate .pyi stubs from the built extension
|
||||
$(DOCKER_RUN) bash /io/scripts/stubgen.sh
|
||||
|
||||
publish: ## Publish wheelhouse/*.whl to the Gitea package registry
|
||||
@test -f .secrets || { echo "missing .secrets (UV_PUBLISH_USERNAME/PASSWORD)"; exit 1; }
|
||||
set -a; . ./.secrets; set +a; \
|
||||
uv publish --publish-url $(PUBLISH_URL) wheelhouse/*.whl
|
||||
|
||||
version: ## Print the wheel version
|
||||
@grep '^version' pyproject.toml | head -1 | cut -d'"' -f2
|
||||
|
||||
clean: ## Remove build output (keeps the docker cache volume)
|
||||
rm -rf build dist wheelhouse python/OCP/*.pyi python/n3xd_ocp/*.pyi
|
||||
|
||||
clean-cache: ## Drop the persistent build cache
|
||||
rm -rf $(CACHE)/ccache $(CACHE)/skbuild $(CACHE)/pip
|
||||
|
||||
test-asan: ## Run the handle tests under AddressSanitizer
|
||||
$(DOCKER_RUN) bash /io/scripts/asan.sh
|
||||
43
README.md
43
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 `<occt-version>.N`, so the kernel a wheel wraps is readable from
|
||||
its version alone. The registry refuses to republish a version; iteration
|
||||
builds therefore carry a `.devN` suffix and are the only ones the registry's
|
||||
cleanup rule collects.
|
||||
```bash
|
||||
uv pip install --index-url https://git.stroblme.de/api/packages/N3XD/pypi/simple/ \
|
||||
--prerelease=allow n3xd-ocp
|
||||
```
|
||||
|
||||
Versions are `<occt-version>.N`, enforced at configure time against the OCCT
|
||||
actually found, so the kernel a wheel wraps is readable from its version alone.
|
||||
The registry refuses to republish a version; iteration builds therefore carry a
|
||||
`.devN` suffix and are the only ones the registry's cleanup rule collects.
|
||||
|
||||
99
docs/adding-symbols.md
Normal file
99
docs/adding-symbols.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Adding symbols
|
||||
|
||||
The routine task: the app needs an OCCT class this binding does not expose yet.
|
||||
Read [design.md](design.md) first if you are touching the machinery instead.
|
||||
|
||||
## 1. Find what is missing
|
||||
|
||||
```bash
|
||||
python tools/inventory.py --emit # re-scan the app for OCP usage
|
||||
python tools/inventory.py --check # what the installed wheel lacks, by module
|
||||
```
|
||||
|
||||
`--check` groups gaps by module, which is how increments are scoped. Note it
|
||||
only sees symbols reached through an import (`TopExp.MapShapes_s`), not methods
|
||||
called on instances (`shape.IsSame(...)`) — a green `--check` is necessary, not
|
||||
sufficient. Running the app's own tests in the parity venv is what catches the
|
||||
rest, loudly, as an `AttributeError`.
|
||||
|
||||
## 2. Write the module
|
||||
|
||||
One file per OCP module: `src/modules/mod_<Name>.cpp`.
|
||||
|
||||
```cpp
|
||||
#include "../common/occt_module.h" // brings in the handle caster and "_a"
|
||||
#include "../common/occt_policies.h" // OCP_RETURN_COPY, OCP_NOGIL
|
||||
|
||||
#include <Some_Class.hxx>
|
||||
|
||||
void register_Some(nb::module_ &root) {
|
||||
nb::module_ m = ocp_submodule(root, "Some");
|
||||
|
||||
nb::class_<Some_Class>(m, "Some_Class")
|
||||
.def(nb::init<>())
|
||||
.def("Value", &Some_Class::Value, "index"_a, OCP_RETURN_COPY)
|
||||
.def("Build", &Some_Class::Build, OCP_NOGIL);
|
||||
}
|
||||
```
|
||||
|
||||
Declare and call `register_Some` in `src/core.cpp`. Registration order matters
|
||||
only in that a base class must precede its derived classes.
|
||||
|
||||
## 3. The checklist
|
||||
|
||||
- **Shape-returning API** → `OCP_RETURN_COPY`. Explorers, iterators, map
|
||||
lookups, `Generated`/`Modified` lists — anything handing out a reference into
|
||||
storage the caller does not own.
|
||||
- **Static method** → `OCP_DEF_S(cls, "Name", ...)`, which appends `_s`. Every
|
||||
static, without exception.
|
||||
- **Transient (handle-managed) class** → derive from `Standard_Transient` in the
|
||||
`nb::class_` declaration and bind constructors with `ocp_new<T, Args...>()`.
|
||||
Never `nb::init<>` — see design.md.
|
||||
- **Long kernel call** → `OCP_NOGIL`, but only if it cannot re-enter Python.
|
||||
- **Executing constructor** → do not bind it. Bind the default constructor plus
|
||||
the `SetX`/`Build` sequence.
|
||||
- **Enum** → `nb::is_arithmetic()` and `.export_values()`.
|
||||
- **`Message_ProgressRange` parameters** → omit them. The app never passes one
|
||||
(no `OCP.Message` import anywhere), and leaving them out keeps signatures
|
||||
small. Add the module if `--check` ever reports it.
|
||||
- **Out-parameters** stay out-parameters. `BRep_Tool.Triangulation_s(F, L)`
|
||||
writes through `L` because the app calls it that way; returning a tuple would
|
||||
be tidier and wrong.
|
||||
|
||||
When in doubt about a signature, ask the stock wheel rather than guessing:
|
||||
|
||||
```bash
|
||||
cd ../app && .venv/bin/python -c "from OCP.BRep import BRep_Tool; print(BRep_Tool.Triangulation_s.__doc__)"
|
||||
```
|
||||
|
||||
## 4. New toolkits
|
||||
|
||||
If the linker cannot find a symbol, the class lives in a toolkit not yet listed
|
||||
in `CMakeLists.txt` (`target_link_libraries(_OCP PRIVATE ...)`). Add it there;
|
||||
`auditwheel` bundles whatever the linker records, so nothing else changes.
|
||||
|
||||
## 5. Verify and ship
|
||||
|
||||
```bash
|
||||
make dev # compile + tests
|
||||
make test-asan # if you touched ownership or added transients
|
||||
make wheel # bump the .devN in pyproject.toml first
|
||||
make publish
|
||||
tools/parity_venv.sh && python tools/inventory.py --check
|
||||
```
|
||||
|
||||
Then run the slice of the app's suite the increment claims — Inc 1 is gated on
|
||||
`backend/tests/test_geom_memo.py`, Inc 2 on the tessellation tests plus
|
||||
`pytest -m perf`, Inc 4 on `test_cad_pool.py` and `test_derive.py`, and the
|
||||
cutover on the full suite plus `backend/tools/rebuild_sweep.py --diff` over the
|
||||
project store.
|
||||
|
||||
## Adding to `n3xd_ocp` instead
|
||||
|
||||
Anything that is not a faithful mirror of an upstream symbol belongs in
|
||||
`src/ext/` under the `n3xd_ocp` namespace: bulk array APIs, batched measurement,
|
||||
anything GIL-free that upstream does not offer. `OCP.*` staying a
|
||||
symbol-for-symbol drop-in is what makes parity testing meaningful, so keep
|
||||
additive work out of it. Register leaf modules with
|
||||
`ocp_named_module("n3xd_ocp.<name>")` and re-export them in
|
||||
`python/n3xd_ocp/__init__.py`.
|
||||
110
docs/building.md
Normal file
110
docs/building.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Building
|
||||
|
||||
Everything runs through `make`; `make help` lists the targets. All compilation
|
||||
happens inside the OCCT builder image, so the only host requirements are Docker
|
||||
and (for publishing) `uv`.
|
||||
|
||||
## The builder image
|
||||
|
||||
`occt/Dockerfile` compiles OCCT 7.9.3 once inside
|
||||
`quay.io/pypa/manylinux_2_28_x86_64` (both the base digest and the source
|
||||
tarball's sha256 are pinned) and installs it to `/opt/occt`.
|
||||
|
||||
```bash
|
||||
make image # ~40 min on 16 cores
|
||||
make image-push # needs: docker login git.stroblme.de
|
||||
```
|
||||
|
||||
It is a **compiler appliance**: wheel builds mount the repo into it rather than
|
||||
`FROM` it, so iterating on the binding never re-layers the kernel. Add new
|
||||
packages at the *end* of the Dockerfile — earlier layers stay cached and the
|
||||
kernel is not recompiled.
|
||||
|
||||
The configuration turns Draw, VTK, Tk, Xlib, OpenGL and GLES off and FreeType
|
||||
on, and the last layer asserts the result: TKService and TKV3d exist (text
|
||||
emboss reaches `Font_BRepFont` through them), neither carries a `libGL`/`libX11`
|
||||
`DT_NEEDED`, and freetype is linked. That is what lets the app image eventually
|
||||
drop `libgl1` and `libx11-6`. Flags are `-O2`, no `-ffast-math`, no
|
||||
`-march=native`: OCCT's version is a determinism input for assay's goldens, so
|
||||
the binding must not introduce a different FP contract than the kernel it wraps.
|
||||
|
||||
**Production never compiles OCCT.** The host has 4 cores and `make update` is a
|
||||
`git pull` plus a compose build; the kernel arrives prebuilt inside the wheel,
|
||||
which is the whole reason this image exists.
|
||||
|
||||
## Build cache
|
||||
|
||||
Object files, ccache, the pip cache and the build venv live under `$(CACHE)`,
|
||||
default `/mnt/cache/n3xd/ocp` — off the root filesystem, which is tight on the
|
||||
dev box. Change it per invocation with `make CACHE=/somewhere wheel`, or reset
|
||||
it with `make clean-cache`.
|
||||
|
||||
## The loop
|
||||
|
||||
```bash
|
||||
make dev # incremental compile + pytest — the inner loop, seconds
|
||||
make test-asan # handle-model memory-safety check
|
||||
make wheel # full build: compile, stubs, repack, auditwheel, smoke test
|
||||
```
|
||||
|
||||
`make wheel` compiles twice on purpose: stubs are produced by importing the
|
||||
freshly built extension, so they cannot exist before the first compile, and the
|
||||
wheel is packed from the source tree. The second pass is incremental.
|
||||
|
||||
The smoke test installs the repaired wheel into a bare venv and imports it with
|
||||
`LD_LIBRARY_PATH` unset — the only honest proof that `auditwheel` made it
|
||||
self-contained.
|
||||
|
||||
## Fixtures
|
||||
|
||||
`tests/data/*.brep` are the byte-identity references and are generated under the
|
||||
**stock** wheel, from the app checkout:
|
||||
|
||||
```bash
|
||||
cd ../app && .venv/bin/python ../ocp/tools/gen_fixtures.py
|
||||
```
|
||||
|
||||
The generator asserts stock is idempotent for each fixture before recording its
|
||||
digest — otherwise the gate would compare against a moving target. Regenerate
|
||||
only when deliberately re-blessing (e.g. the OCCT 8.0 bump).
|
||||
|
||||
## Publishing
|
||||
|
||||
```bash
|
||||
make version # confirm what you are about to publish
|
||||
make publish # uv publish -> https://git.stroblme.de/api/packages/N3XD/pypi
|
||||
```
|
||||
|
||||
Credentials come from `.secrets` (gitignored) as `UV_PUBLISH_USERNAME` /
|
||||
`UV_PUBLISH_PASSWORD`; the username is a real Gitea username, not the PyPI
|
||||
`__token__` convention, and the token needs `package: Read and Write`.
|
||||
|
||||
A version can never be republished. Iteration builds therefore carry a `.devN`
|
||||
suffix and are the only ones the registry's cleanup rule collects; bump `N` in
|
||||
`pyproject.toml` for each upload.
|
||||
|
||||
Consumers read anonymously — the N3XD org is public:
|
||||
|
||||
```bash
|
||||
uv pip install --index-url https://git.stroblme.de/api/packages/N3XD/pypi/simple/ \
|
||||
--prerelease=allow n3xd-ocp
|
||||
```
|
||||
|
||||
Gitea serves no root `/simple/` listing (404), only the per-package path, which
|
||||
is all pip and uv ask for.
|
||||
|
||||
## Parity
|
||||
|
||||
`tools/parity_venv.sh` builds a side environment where the app runs against this
|
||||
wheel instead of the stock one. The app's manifests are never edited: both
|
||||
distributions own the `OCP/` import path and a process can hold only one OCCT
|
||||
build, so a swap is per-environment and reversible by re-syncing.
|
||||
|
||||
```bash
|
||||
tools/parity_venv.sh # from the registry
|
||||
tools/parity_venv.sh --local # from wheelhouse/
|
||||
python tools/inventory.py --check
|
||||
```
|
||||
|
||||
Coverage is expected to be partial until the increments land — `--check` prints
|
||||
what is still missing, grouped by module, which is the work queue.
|
||||
195
docs/design.md
Normal file
195
docs/design.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Design record
|
||||
|
||||
Decisions that are expensive to revisit, and the evidence behind them. If you
|
||||
are adding symbols rather than changing the machinery, read
|
||||
[adding-symbols.md](adding-symbols.md) instead.
|
||||
|
||||
## What this is
|
||||
|
||||
A hand-written [nanobind](https://github.com/wjakob/nanobind) binding of the
|
||||
OCCT surface the N3XD backend actually uses — 139 symbols across 48 `OCP.*`
|
||||
modules, per `tools/inventory.py`, not all of OCCT. It installs as a top-level
|
||||
`OCP`, so it is a drop-in replacement for `cadquery-ocp-novtk` and the app's 442
|
||||
import sites stay untouched.
|
||||
|
||||
Binding *call* overhead was never the bottleneck — the CAD hotspots are inside
|
||||
the C++ kernel — so the payoff is version velocity, footprint, correctness at
|
||||
the ownership boundary, and the freedom to add APIs upstream cannot (GIL
|
||||
release, bulk array extraction).
|
||||
|
||||
## The handle model
|
||||
|
||||
`opencascade::handle<T>` is cast by `src/common/occt_handle.h`, modelled on
|
||||
nanobind's own `stl/shared_ptr.h`.
|
||||
|
||||
- **C++ → Python**: the wrapper is a *non-owning* nanobind instance pointing at
|
||||
the C++ object, plus one handle stored in its keep-alive list. An existing
|
||||
wrapper is reused (`is_new == false`), so identity holds while a wrapper is
|
||||
alive, and a long-lived object crossing the boundary repeatedly does not pile
|
||||
up redundant references. Transients are polymorphic, so `nb_type_put_p`
|
||||
downcasts on the dynamic type.
|
||||
- **Python → C++**: a plain handle copy, balanced when the caster dies after the
|
||||
call. Unlike `shared_ptr`, no Python reference is taken: OCCT's intrusive
|
||||
atomic refcount owns the object's memory, not the Python instance. That is
|
||||
precisely what makes releasing the GIL safe — OCCT may copy handles on its own
|
||||
worker threads without touching the interpreter.
|
||||
|
||||
**Transient constructors never use `nb::init<>`.** nanobind's normal
|
||||
constructor placement-news the object into the Python instance's storage, which
|
||||
OCCT would later `delete`. Use `ocp_new<T, Args...>()` (`occt_transient.h`),
|
||||
which heap-allocates and returns a handle. This is not hypothetical: the app
|
||||
builds a `Geom_BSplineCurve` in `sketch_builder/edges.py` and hands it to
|
||||
`BRepBuilderAPI_MakeEdge`, which keeps a handle past the call.
|
||||
|
||||
The caster enforces the rule rather than trusting it: a transient whose
|
||||
`GetRefCount()` is zero is not handle-owned, and converting it would hand OCCT
|
||||
the right to free a nanobind instance. `from_python` refuses instead — a
|
||||
TypeError beats heap corruption, and only a binding bug can reach it.
|
||||
|
||||
*Alternative considered*: `nb::intrusive_ptr` plus a side table mapping
|
||||
`Standard_Transient*` to `PyObject*` (OCCT objects have no self-py slot). It
|
||||
loses on the property that matters most here — nanobind's intrusive protocol
|
||||
unifies the C++ count with the PyObject refcount, so `Py_INCREF` from a
|
||||
GIL-free OCCT thread becomes a crash class. Kept as the documented fallback if
|
||||
the caster above ever proves unworkable.
|
||||
|
||||
*Verification*: `tests/test_handles.py`, run both normally and under
|
||||
`make test-asan`. It covers wrapper identity across a round trip, survival in
|
||||
both directions when one side drops its reference, refcount balance across 1000
|
||||
conversions and across 100 raising calls, null-handle ↔ `None`, and RSS growth
|
||||
across 50 000 create/destroy cycles. ASAN covers memory *safety*; the RSS
|
||||
assertion covers *growth* and is skipped under ASAN, whose quarantine retains
|
||||
freed memory (139 MB of it, which is what a naive reading would call a leak).
|
||||
|
||||
## Fidelity rules
|
||||
|
||||
Verified against the installed stock wheel, not assumed.
|
||||
|
||||
**`__hash__` is bound; `__eq__` is not.** Upstream binds `__hash__` (TShape ⊕
|
||||
Location) and leaves `__eq__` at Python's default identity comparison, so two
|
||||
re-extracted copies of one face hash equal but compare unequal. That pairing
|
||||
looks like a bug and is load-bearing: `cad/topology/geom_memo.py` buckets on
|
||||
`hash(face)` and disambiguates with `IsSame` *because* `==` cannot be trusted.
|
||||
Binding `__eq__` to `IsEqual` would quietly collapse entries that memo keeps
|
||||
apart. Hash *values* need not match upstream — only the semantics do.
|
||||
|
||||
**Sub-shapes are returned by value** (`OCP_RETURN_COPY`) from explorers,
|
||||
iterators, map lookups and history lists. A `TopoDS_Shape` is a small value
|
||||
holding a handle to its TShape, so a copy is one incref and owns what it points
|
||||
at. This makes the lifetime class that segfaulted a process-global face memo
|
||||
unrepresentable.
|
||||
|
||||
**Executing constructors are not bound.** `BRepAlgoAPI_*` gets a default
|
||||
constructor plus `SetArguments`/`SetTools`/`Build` — the two-argument forms run
|
||||
the algorithm immediately, which is how a latent double-execution survived in
|
||||
the app for a while.
|
||||
|
||||
**`_s` on every static**, via `OCP_DEF_S`. The rule is blanket rather than
|
||||
clash-driven, so no static can ship without it; the app calls 176 of them.
|
||||
|
||||
**Enums** use `nb::is_arithmetic()` + `export_values()`, reproducing pybind11's
|
||||
int comparison and module-scope members (`from OCP.TopAbs import TopAbs_FACE`).
|
||||
|
||||
**Exceptions**: `Standard_Failure` derives `RuntimeError`, which is what keeps
|
||||
the backend's `except RuntimeError` sites working — it never names an OCCT
|
||||
class. About 20 concrete types are bound under `OCP.Standard` / `OCP.StdFail`
|
||||
and dispatched on the dynamic OCCT type, because cad_pool's children marshal
|
||||
failures home as `f"{type(exc).__name__}: {exc}"`, making the name observable.
|
||||
|
||||
**`TopoDS` is a namespace in OCCT 7.9**, not a class. Upstream still presents it
|
||||
as a class carrying the `_s` statics, and the app calls `TopoDS.Face_s(...)`, so
|
||||
`mod_TopoDS.cpp` binds an empty carrier struct under that name.
|
||||
|
||||
## GIL policy
|
||||
|
||||
Released around calls that stay inside the kernel and cannot re-enter Python:
|
||||
`Build`/`Perform`, meshing, `BRepCheck_Analyzer`, file readers and writers, and
|
||||
every `n3xd_ocp` bulk API. Applied from an explicit list, never blanket.
|
||||
|
||||
`BinTools` is *also* GIL-free for the kernel half, which the original plan
|
||||
assumed impossible. Rather than bridging a `streambuf` that calls back into
|
||||
Python per chunk, `occt_stream.h` slurps the file-like object first and hands
|
||||
the kernel a pure C++ stream. That is correct regardless of how BinTools seeks,
|
||||
and costs one extra copy of the payload — which `n3xd_ocp.bintools` avoids
|
||||
entirely for the pool paths that care.
|
||||
|
||||
**None as an argument** is rejected before the caster for simple overloads, so
|
||||
handle parameters that legitimately accept a null handle need an explicit
|
||||
`nb::arg("x").none()`. Null *returns* map to `None` unconditionally. The app
|
||||
passes no null handles today; `inventory.py` plus the app suite are the guard.
|
||||
|
||||
## Packaging
|
||||
|
||||
**One extension** (`OCP/_OCP.abi3.so`) registering every `OCP.*` submodule via
|
||||
`PyImport_AddModule`, so `import OCP.TopoDS` works with no shim module per name
|
||||
and `cls.__module__` reads `OCP.TopoDS`. The whole surface traffics in
|
||||
`TopoDS_Shape`, `gp_*` and handles, so sharing types in-process is free here and
|
||||
would otherwise lean on nanobind's cross-extension registry; registration order
|
||||
stays an explicit sequence in `core.cpp`; and cad_pool's forkserver warms one
|
||||
dlopen. One `.cpp` per module keeps incremental compiles cheap — only the link
|
||||
is shared.
|
||||
|
||||
**abi3 (`cp312-abi3`)** from the first build, matching forge/assay, so the
|
||||
Python 3.13 bump (roadmap 10D) needs no rebuild. Escape hatch if a nanobind
|
||||
STABLE_ABI limitation ever bites: drop `STABLE_ABI` and `wheel.py-api`, since
|
||||
every deployed environment is 3.12. Stub generation works fine under abi3.
|
||||
|
||||
**Version `<occt>.N`**, asserted at configure time against the OCCT actually
|
||||
found, so `occt_version()` keeps reporting the truth for assay's goldens.
|
||||
Iteration builds carry `.devN`; the registry never allows republishing.
|
||||
|
||||
**No sdist is ever published** — it could not build without the builder image,
|
||||
and offering one invites the 4-core production host to try. That is enforced by
|
||||
only ever building wheels. Note `sdist.exclude` is *not* the way to do it:
|
||||
scikit-build-core feeds it into the wheel's package-file mapping too, so
|
||||
excluding `*` silently ships a wheel containing the compiled extension and none
|
||||
of the Python package. Relatedly, file selection is git-based, so the generated
|
||||
`.pyi` stubs are gitignored *and* re-included through `sdist.include`.
|
||||
|
||||
**Fork safety**: import starts no threads and creates no fork-hostile state, so
|
||||
cad_pool's forkserver keeps costing ~30 ms per job instead of a ~1.3 s spawn.
|
||||
Pinned by `tests/test_forksafety.py`.
|
||||
|
||||
## The `n3xd_ocp` module
|
||||
|
||||
`OCP.*` stays a symbol-for-symbol drop-in so parity testing means something;
|
||||
anything additive lives in `n3xd_ocp`, shipped in the same wheel and backed by
|
||||
the same OCCT build. Only the leaf submodules are registered from C++ — creating
|
||||
the parent would put a bare module in `sys.modules` and a later `import
|
||||
n3xd_ocp` would skip the package's `__init__.py`.
|
||||
|
||||
Shipped: `bintools` (shape ↔ `bytes`, GIL-free, byte-identical to
|
||||
`OCP.BinTools` and asserted so) and `_debug` (test-only ownership
|
||||
introspection).
|
||||
|
||||
Designed, landing with the increment that binds their types:
|
||||
|
||||
```python
|
||||
# with Inc 1 (BRepGProp/GProp) — attacks the measured 94 % of
|
||||
# face_candidate_anchors (0.99 s of 1.05 s for 690 faces) that is
|
||||
# BRepGProp.SurfaceProperties_s called once per face from Python
|
||||
def face_surface_props(shape, *, parallel=True) -> tuple[ndarray, ndarray]
|
||||
# areas[F], centroids[F,3], ordered by TopExp.MapShapes_s(FACE) index
|
||||
|
||||
# with Inc 2 (BRepMesh/Poly) — replaces the per-node and per-triangle Python
|
||||
# loops in cad/tessellation.py
|
||||
class FaceMesh: nodes, triangles, normals, uv, face_index
|
||||
def extract_meshes(shape, *, want_normals=True, want_uv=False,
|
||||
apply_location=True, flip_reversed=True) -> list[FaceMesh]
|
||||
|
||||
# with Inc 4 — opt-in experiment: OSD::SetSignal turns some native faults into
|
||||
# catchable Standard_Failure subclasses inside cad_pool children. Never called
|
||||
# at import; subprocess isolation stays regardless.
|
||||
def set_signal(arm_fpe: bool = False) -> None
|
||||
```
|
||||
|
||||
The backend adopts these after cutover, one call site at a time.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **S5** — whether STEP needs `CSF_*` resource files shipped in the wheel.
|
||||
Modern OCCT code-initialises most `Interface_Static` defaults; settle it when
|
||||
Inc 3 (I/O) lands, in a container without system OCCT resources.
|
||||
- **Byte-identity beyond 7.9.3** — the gate compares against the stock wheel, so
|
||||
it necessarily retires at the OCCT 8.0 bump (roadmap 10E), where the fixtures
|
||||
are re-blessed deliberately alongside assay's goldens.
|
||||
291
inventory.json
Normal file
291
inventory.json
Normal file
@@ -0,0 +1,291 @@
|
||||
{
|
||||
"app_root": "/home/stroblme/n3xd/app/backend",
|
||||
"files_importing_ocp": 115,
|
||||
"modules": {
|
||||
"BOPAlgo": {
|
||||
"BOPAlgo_GlueShift": []
|
||||
},
|
||||
"BRep": {
|
||||
"BRep_Builder": [],
|
||||
"BRep_Tool": [
|
||||
"Degenerated_s",
|
||||
"Pnt_s",
|
||||
"Surface_s",
|
||||
"Tolerance_s",
|
||||
"Triangulation_s"
|
||||
]
|
||||
},
|
||||
"BRepAdaptor": {
|
||||
"BRepAdaptor_Curve": [],
|
||||
"BRepAdaptor_Surface": []
|
||||
},
|
||||
"BRepAlgoAPI": {
|
||||
"BRepAlgoAPI_Common": [],
|
||||
"BRepAlgoAPI_Cut": [],
|
||||
"BRepAlgoAPI_Fuse": [],
|
||||
"BRepAlgoAPI_Section": [],
|
||||
"BRepAlgoAPI_Splitter": []
|
||||
},
|
||||
"BRepBndLib": {
|
||||
"BRepBndLib": [
|
||||
"Add_s"
|
||||
]
|
||||
},
|
||||
"BRepBuilderAPI": {
|
||||
"BRepBuilderAPI_Copy": [],
|
||||
"BRepBuilderAPI_MakeEdge": [],
|
||||
"BRepBuilderAPI_MakeFace": [],
|
||||
"BRepBuilderAPI_MakePolygon": [],
|
||||
"BRepBuilderAPI_MakeWire": [],
|
||||
"BRepBuilderAPI_Sewing": [],
|
||||
"BRepBuilderAPI_Transform": []
|
||||
},
|
||||
"BRepCheck": {
|
||||
"BRepCheck_Analyzer": [],
|
||||
"BRepCheck_Status": [
|
||||
"BRepCheck_IntersectingWires",
|
||||
"BRepCheck_SelfIntersectingWire"
|
||||
]
|
||||
},
|
||||
"BRepClass3d": {
|
||||
"BRepClass3d_SolidClassifier": []
|
||||
},
|
||||
"BRepExtrema": {
|
||||
"BRepExtrema_DistShapeShape": []
|
||||
},
|
||||
"BRepFilletAPI": {
|
||||
"BRepFilletAPI_MakeChamfer": [],
|
||||
"BRepFilletAPI_MakeFillet": []
|
||||
},
|
||||
"BRepGProp": {
|
||||
"BRepGProp": [
|
||||
"LinearProperties_s",
|
||||
"SurfaceProperties_s",
|
||||
"VolumeProperties_s"
|
||||
]
|
||||
},
|
||||
"BRepLib": {
|
||||
"BRepLib": [
|
||||
"BuildCurves3d_s",
|
||||
"OrientClosedSolid_s"
|
||||
]
|
||||
},
|
||||
"BRepMesh": {
|
||||
"BRepMesh_IncrementalMesh": []
|
||||
},
|
||||
"BRepOffsetAPI": {
|
||||
"BRepOffsetAPI_DraftAngle": [],
|
||||
"BRepOffsetAPI_MakePipe": [],
|
||||
"BRepOffsetAPI_MakePipeShell": [],
|
||||
"BRepOffsetAPI_MakeThickSolid": [],
|
||||
"BRepOffsetAPI_ThruSections": []
|
||||
},
|
||||
"BRepPrimAPI": {
|
||||
"BRepPrimAPI_MakeBox": [],
|
||||
"BRepPrimAPI_MakeCone": [],
|
||||
"BRepPrimAPI_MakeCylinder": [],
|
||||
"BRepPrimAPI_MakeHalfSpace": [],
|
||||
"BRepPrimAPI_MakePrism": [],
|
||||
"BRepPrimAPI_MakeRevol": []
|
||||
},
|
||||
"BRepTools": {
|
||||
"BRepTools": [
|
||||
"Clean_s",
|
||||
"OuterWire_s",
|
||||
"UVBounds_s"
|
||||
],
|
||||
"BRepTools_WireExplorer": []
|
||||
},
|
||||
"BinTools": {
|
||||
"BinTools": [
|
||||
"Read_s",
|
||||
"Write_s"
|
||||
]
|
||||
},
|
||||
"Bnd": {
|
||||
"Bnd_Box": []
|
||||
},
|
||||
"GC": {
|
||||
"GC_MakeArcOfCircle": []
|
||||
},
|
||||
"GCE2d": {
|
||||
"GCE2d_MakeLine": [],
|
||||
"GCE2d_MakeSegment": []
|
||||
},
|
||||
"GCPnts": {
|
||||
"GCPnts_AbscissaPoint": [
|
||||
"Length_s"
|
||||
],
|
||||
"GCPnts_TangentialDeflection": [],
|
||||
"GCPnts_UniformDeflection": []
|
||||
},
|
||||
"GProp": {
|
||||
"GProp_GProps": []
|
||||
},
|
||||
"Geom": {
|
||||
"Geom_BSplineCurve": [],
|
||||
"Geom_CylindricalSurface": [],
|
||||
"Geom_OffsetSurface": []
|
||||
},
|
||||
"GeomAPI": {
|
||||
"GeomAPI_PointsToBSplineSurface": [],
|
||||
"GeomAPI_ProjectPointOnSurf": []
|
||||
},
|
||||
"GeomAbs": {
|
||||
"GeomAbs_BSplineCurve": [],
|
||||
"GeomAbs_BSplineSurface": [],
|
||||
"GeomAbs_BezierCurve": [],
|
||||
"GeomAbs_BezierSurface": [],
|
||||
"GeomAbs_C2": [],
|
||||
"GeomAbs_Circle": [],
|
||||
"GeomAbs_Cone": [],
|
||||
"GeomAbs_Cylinder": [],
|
||||
"GeomAbs_Ellipse": [],
|
||||
"GeomAbs_Line": [],
|
||||
"GeomAbs_Plane": [],
|
||||
"GeomAbs_Sphere": [],
|
||||
"GeomAbs_Torus": []
|
||||
},
|
||||
"GeomLProp": {
|
||||
"GeomLProp_SLProps": []
|
||||
},
|
||||
"Graphic3d": {
|
||||
"Graphic3d_HTA_CENTER": [],
|
||||
"Graphic3d_HTA_LEFT": [],
|
||||
"Graphic3d_HTA_RIGHT": [],
|
||||
"Graphic3d_VTA_BOTTOM": [],
|
||||
"Graphic3d_VTA_CENTER": [],
|
||||
"Graphic3d_VTA_TOP": []
|
||||
},
|
||||
"IFSelect": {
|
||||
"IFSelect_RetDone": []
|
||||
},
|
||||
"IGESControl": {
|
||||
"IGESControl_Controller": [
|
||||
"Init_s"
|
||||
],
|
||||
"IGESControl_Reader": [],
|
||||
"IGESControl_Writer": []
|
||||
},
|
||||
"IntCurvesFace": {
|
||||
"IntCurvesFace_ShapeIntersector": []
|
||||
},
|
||||
"Interface": {
|
||||
"Interface_Static": [
|
||||
"CVal_s",
|
||||
"SetCVal_s"
|
||||
]
|
||||
},
|
||||
"NCollection": {
|
||||
"NCollection_Utf8String": []
|
||||
},
|
||||
"Poly": {
|
||||
"Poly_Triangulation": []
|
||||
},
|
||||
"RWStl": {
|
||||
"RWStl": [
|
||||
"ReadFile_s"
|
||||
]
|
||||
},
|
||||
"STEPControl": {
|
||||
"STEPControl_AsIs": [],
|
||||
"STEPControl_Controller": [
|
||||
"Init_s"
|
||||
],
|
||||
"STEPControl_Reader": [],
|
||||
"STEPControl_Writer": []
|
||||
},
|
||||
"ShapeAnalysis": {
|
||||
"ShapeAnalysis_FreeBounds": [
|
||||
"ConnectEdgesToWires_s"
|
||||
]
|
||||
},
|
||||
"ShapeFix": {
|
||||
"ShapeFix_Face": [],
|
||||
"ShapeFix_Shape": [],
|
||||
"ShapeFix_Solid": []
|
||||
},
|
||||
"ShapeUpgrade": {
|
||||
"ShapeUpgrade_UnifySameDomain": []
|
||||
},
|
||||
"StdPrs": {
|
||||
"StdPrs_BRepFont": [],
|
||||
"StdPrs_BRepTextBuilder": []
|
||||
},
|
||||
"StlAPI": {
|
||||
"StlAPI_Writer": []
|
||||
},
|
||||
"TColStd": {
|
||||
"TColStd_Array1OfInteger": [],
|
||||
"TColStd_Array1OfReal": [],
|
||||
"TColStd_SequenceOfAsciiString": []
|
||||
},
|
||||
"TColgp": {
|
||||
"TColgp_Array1OfPnt": [],
|
||||
"TColgp_Array2OfPnt": []
|
||||
},
|
||||
"TopAbs": {
|
||||
"TopAbs_EDGE": [],
|
||||
"TopAbs_FACE": [],
|
||||
"TopAbs_IN": [],
|
||||
"TopAbs_ON": [],
|
||||
"TopAbs_OUT": [],
|
||||
"TopAbs_REVERSED": [],
|
||||
"TopAbs_SHELL": [],
|
||||
"TopAbs_SOLID": [],
|
||||
"TopAbs_ShapeEnum": [
|
||||
"TopAbs_SHELL"
|
||||
],
|
||||
"TopAbs_VERTEX": [],
|
||||
"TopAbs_WIRE": []
|
||||
},
|
||||
"TopExp": {
|
||||
"TopExp": [
|
||||
"MapShapesAndAncestors_s",
|
||||
"MapShapes_s"
|
||||
],
|
||||
"TopExp_Explorer": []
|
||||
},
|
||||
"TopLoc": {
|
||||
"TopLoc_Location": []
|
||||
},
|
||||
"TopTools": {
|
||||
"TopTools_HSequenceOfShape": [],
|
||||
"TopTools_IndexedDataMapOfShapeListOfShape": [],
|
||||
"TopTools_IndexedMapOfShape": [],
|
||||
"TopTools_ListOfShape": []
|
||||
},
|
||||
"TopoDS": {
|
||||
"TopoDS": [
|
||||
"Edge_s",
|
||||
"Face_s",
|
||||
"Shell_s",
|
||||
"Solid_s",
|
||||
"Vertex_s",
|
||||
"Wire_s"
|
||||
],
|
||||
"TopoDS_Compound": [],
|
||||
"TopoDS_Edge": [],
|
||||
"TopoDS_Face": [],
|
||||
"TopoDS_Shape": [],
|
||||
"TopoDS_Shell": [],
|
||||
"TopoDS_Wire": []
|
||||
},
|
||||
"gp": {
|
||||
"gp_Ax1": [],
|
||||
"gp_Ax2": [],
|
||||
"gp_Ax3": [],
|
||||
"gp_Circ": [],
|
||||
"gp_Dir": [],
|
||||
"gp_Dir2d": [],
|
||||
"gp_Identity": [],
|
||||
"gp_Lin": [],
|
||||
"gp_Pln": [],
|
||||
"gp_Pnt": [],
|
||||
"gp_Pnt2d": [],
|
||||
"gp_Quaternion": [],
|
||||
"gp_Trsf": [],
|
||||
"gp_Vec": []
|
||||
}
|
||||
}
|
||||
}
|
||||
79
occt/Dockerfile
Normal file
79
occt/Dockerfile
Normal file
@@ -0,0 +1,79 @@
|
||||
# OCCT builder image — a compiler appliance, not a base for the wheel.
|
||||
#
|
||||
# Wheel builds `docker run` this with the repo mounted (see ../Makefile); they
|
||||
# never `FROM` it, so iterating on the binding never re-layers the kernel.
|
||||
# Rebuild only when this file changes, and bump the image tag's -N when you do.
|
||||
#
|
||||
# The production host (4 cores) can never compile OCCT, which is the whole
|
||||
# reason this exists: the kernel is compiled once here and shipped inside the
|
||||
# wheel by `auditwheel repair`.
|
||||
|
||||
FROM quay.io/pypa/manylinux_2_28_x86_64@sha256:f854c50adf7b7a325bc4794316f3758d387a41d61f9e2ebca0f26c7dc8f761d4
|
||||
|
||||
# FreeType + fontconfig are the only optional OCCT dependencies we keep — the
|
||||
# text-emboss feature builds glyph outlines through Font_BRepFont (TKService).
|
||||
# ccache/ninja serve the wheel build that runs in this image later.
|
||||
RUN dnf install -y freetype-devel fontconfig-devel ninja-build ccache valgrind \
|
||||
&& dnf clean all
|
||||
|
||||
ARG OCCT_TAG=V7_9_3
|
||||
ARG OCCT_SHA256=5ecf094ec6b12d5413dfb851d8c3590c354058aee556e32e408bdfbf8c357d57
|
||||
|
||||
RUN curl -fsSL -o /tmp/occt.tar.gz \
|
||||
"https://github.com/Open-Cascade-SAS/OCCT/archive/refs/tags/${OCCT_TAG}.tar.gz" \
|
||||
&& echo "${OCCT_SHA256} /tmp/occt.tar.gz" | sha256sum -c - \
|
||||
&& mkdir -p /src \
|
||||
&& tar -xzf /tmp/occt.tar.gz -C /src --strip-components=1 \
|
||||
&& rm /tmp/occt.tar.gz
|
||||
|
||||
# Conservative FP flags are deliberate: OCCT's version is a determinism input
|
||||
# for assay's committed goldens, so a different optimizer could shift last-ulp
|
||||
# results against the upstream wheel we are proving parity with. -O2 (not the
|
||||
# Release default -O3), no -ffast-math, no -march=native.
|
||||
RUN cmake -G Ninja -S /src -B /build \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_CXX_FLAGS_RELEASE="-O2 -DNDEBUG" \
|
||||
-DCMAKE_C_FLAGS_RELEASE="-O2 -DNDEBUG" \
|
||||
-DBUILD_LIBRARY_TYPE=Shared \
|
||||
-DINSTALL_DIR=/opt/occt \
|
||||
-DBUILD_MODULE_FoundationClasses=ON \
|
||||
-DBUILD_MODULE_ModelingData=ON \
|
||||
-DBUILD_MODULE_ModelingAlgorithms=ON \
|
||||
-DBUILD_MODULE_Visualization=ON \
|
||||
-DBUILD_MODULE_DataExchange=ON \
|
||||
-DBUILD_MODULE_ApplicationFramework=ON \
|
||||
-DBUILD_MODULE_Draw=OFF \
|
||||
-DBUILD_MODULE_DETools=OFF \
|
||||
-DUSE_FREETYPE=ON \
|
||||
-DUSE_VTK=OFF -DUSE_TK=OFF -DUSE_TCL=OFF \
|
||||
-DUSE_XLIB=OFF -DUSE_OPENGL=OFF -DUSE_GLES2=OFF \
|
||||
-DUSE_TBB=OFF -DUSE_RAPIDJSON=OFF -DUSE_DRACO=OFF \
|
||||
-DUSE_FREEIMAGE=OFF -DUSE_FFMPEG=OFF -DUSE_OPENVR=OFF \
|
||||
&& ninja -C /build -j"$(nproc)" \
|
||||
&& ninja -C /build install \
|
||||
&& find /opt/occt/lib -name '*.so*' -type f -exec strip --strip-debug {} + \
|
||||
&& rm -rf /build /src
|
||||
|
||||
# Assert the headless build actually produced what the app needs. Text emboss
|
||||
# imports OCP.StdPrs.StdPrs_BRepFont, which is a deprecated typedef of
|
||||
# Font_BRepFont living in TKService/TKV3d — those toolkits must exist even with
|
||||
# OpenGL and Xlib off, and must not have picked up a libGL/libX11 DT_NEEDED
|
||||
# (dropping those two runtime packages from the app image is a phase goal).
|
||||
RUN test -f /opt/occt/lib/libTKService.so \
|
||||
&& test -f /opt/occt/lib/libTKV3d.so \
|
||||
&& test -f /opt/occt/lib/libTKDESTEP.so \
|
||||
&& ! ldd /opt/occt/lib/libTKService.so | grep -qE 'libGL\.|libX11\.' \
|
||||
&& ! ldd /opt/occt/lib/libTKV3d.so | grep -qE 'libGL\.|libX11\.' \
|
||||
&& ldd /opt/occt/lib/libTKService.so | grep -q libfreetype
|
||||
|
||||
ENV OCCT_ROOT=/opt/occt \
|
||||
CMAKE_PREFIX_PATH=/opt/occt \
|
||||
LD_LIBRARY_PATH=/opt/occt/lib \
|
||||
PATH=/opt/python/cp312-cp312/bin:$PATH
|
||||
|
||||
WORKDIR /io
|
||||
|
||||
# Sanitizer runtime for `make test-asan`, the handle-model memory-safety check.
|
||||
# Deliberately the last layer: adding it never invalidates the kernel build
|
||||
# above, so the ~40 minute compile is not repeated for a test dependency.
|
||||
RUN dnf install -y gcc-toolset-14-libasan-devel && dnf clean all
|
||||
46
pyproject.toml
Normal file
46
pyproject.toml
Normal file
@@ -0,0 +1,46 @@
|
||||
[build-system]
|
||||
requires = ["scikit-build-core>=0.11", "nanobind>=2.7"]
|
||||
build-backend = "scikit_build_core.build"
|
||||
|
||||
[project]
|
||||
name = "n3xd-ocp"
|
||||
# <occt-version>.N, so the kernel a wheel wraps is readable from its version
|
||||
# alone and occt_version() keeps reporting the truth. Iteration builds carry a
|
||||
# .devN suffix: the registry never allows republishing a version, and its
|
||||
# cleanup rule collects only the dev ones. CMake asserts the prefix matches
|
||||
# the OCCT it found.
|
||||
version = "7.9.3.1.dev1"
|
||||
description = "nanobind bindings for the OpenCASCADE geometry kernel (drop-in OCP)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { text = "LGPL-2.1-only" }
|
||||
authors = [{ name = "N3XD" }]
|
||||
classifiers = [
|
||||
"Private :: Do Not Upload",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: C++",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://git.stroblme.de/N3XD/ocp"
|
||||
|
||||
[tool.scikit-build]
|
||||
minimum-version = "0.11"
|
||||
build-dir = "build/{wheel_tag}"
|
||||
wheel.packages = ["python/OCP", "python/n3xd_ocp"]
|
||||
# Stubs are generated during the build and gitignored, and file selection is
|
||||
# git-based by default — so they must be re-included by hand or the wheel ships
|
||||
# without the typing surface that retires the backend's ty suppression.
|
||||
sdist.include = ["python/OCP/*.pyi", "python/n3xd_ocp/*.pyi"]
|
||||
# abi3 from day one, matching the forge/assay convention: one wheel spans 3.12
|
||||
# and 3.13, so the interpreter bump (roadmap 10D) needs no rebuild here.
|
||||
wheel.py-api = "cp312"
|
||||
# No sdist is ever published — it could not build without the OCCT builder
|
||||
# image, and offering one would invite the production host (4 cores) to try.
|
||||
# That is enforced by only ever building wheels (scripts/build_wheel.sh) rather
|
||||
# than by an exclude rule here: scikit-build-core feeds sdist.exclude into the
|
||||
# wheel's package-file mapping too, so excluding "*" silently ships a wheel
|
||||
# with the compiled extension and none of the Python package.
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
25
python/OCP/__init__.py
Normal file
25
python/OCP/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""OCP — Python bindings for the OpenCASCADE (OCCT) geometry kernel.
|
||||
|
||||
Installs under the same import path as ``cadquery-ocp``/``cadquery-ocp-novtk``
|
||||
so it is a drop-in replacement; the two can never be installed together, and a
|
||||
process can only ever hold one OCCT build.
|
||||
|
||||
The compiled extension registers every ``OCP.*`` submodule directly in
|
||||
``sys.modules``, so ``import OCP.TopoDS`` works without a shim module per name.
|
||||
This file only re-exports them as attributes, so ``OCP.TopoDS`` also resolves
|
||||
after a bare ``import OCP``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from . import _OCP
|
||||
|
||||
__version__ = _OCP.__version__
|
||||
__occt_version__ = _OCP.__occt_version__
|
||||
|
||||
for _name in _OCP.__all_modules__:
|
||||
setattr(sys.modules[__name__], _name, sys.modules[f"OCP.{_name}"])
|
||||
|
||||
__all__ = [*_OCP.__all_modules__, "__version__", "__occt_version__"]
|
||||
0
python/OCP/py.typed
Normal file
0
python/OCP/py.typed
Normal file
21
python/n3xd_ocp/__init__.py
Normal file
21
python/n3xd_ocp/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""n3xd_ocp — performance APIs that go beyond the upstream OCP surface.
|
||||
|
||||
Kept separate from ``OCP`` on purpose: that namespace stays a symbol-for-symbol
|
||||
drop-in for ``cadquery-ocp-novtk`` so parity testing means something, while
|
||||
anything additive lives here. Shipped in the same wheel, backed by the same
|
||||
OCCT build, and safe to mix with ``OCP`` objects in one process.
|
||||
|
||||
Submodules are registered by the compiled extension; importing ``OCP`` is what
|
||||
loads it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import OCP as _OCP_pkg # noqa: F401 (loads the extension, registering us)
|
||||
|
||||
bintools = sys.modules["n3xd_ocp.bintools"]
|
||||
_debug = sys.modules["n3xd_ocp._debug"]
|
||||
|
||||
__all__ = ["bintools"]
|
||||
0
python/n3xd_ocp/py.typed
Normal file
0
python/n3xd_ocp/py.typed
Normal file
28
scripts/_env.sh
Executable file
28
scripts/_env.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
# Shared environment for the in-container scripts. Sourced, not executed.
|
||||
#
|
||||
# Everything expensive or bulky (ccache, the CMake tree, pip's cache, the build
|
||||
# venv) lives under $CACHE, which the Makefile bind-mounts from outside the
|
||||
# repo — the root filesystem on this host has little headroom, and these
|
||||
# survive between runs to keep the inner loop incremental.
|
||||
|
||||
CACHE="${CACHE_ROOT:-/cache}"
|
||||
export CCACHE_DIR="$CACHE/ccache"
|
||||
export PIP_CACHE_DIR="$CACHE/pip"
|
||||
export HOME="$CACHE" # containers run as the host uid; /root is not ours
|
||||
export CMAKE_C_COMPILER_LAUNCHER=ccache
|
||||
export CMAKE_CXX_COMPILER_LAUNCHER=ccache
|
||||
export CMAKE_PREFIX_PATH=/opt/occt
|
||||
export LD_LIBRARY_PATH=/opt/occt/lib
|
||||
export SKBUILD_BUILD_DIR="$CACHE/skbuild/{wheel_tag}"
|
||||
|
||||
mkdir -p "$CCACHE_DIR" "$PIP_CACHE_DIR"
|
||||
|
||||
VENV="$CACHE/venv"
|
||||
if [ ! -x "$VENV/bin/python" ]; then
|
||||
echo "--- creating build venv at $VENV ---"
|
||||
/opt/python/cp312-cp312/bin/python -m venv "$VENV"
|
||||
"$VENV/bin/pip" install -q --upgrade pip
|
||||
"$VENV/bin/pip" install -q build nanobind scikit-build-core pytest
|
||||
fi
|
||||
PY="$VENV/bin/python"
|
||||
export PY VENV CACHE
|
||||
49
scripts/asan.sh
Executable file
49
scripts/asan.sh
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# Memory-safety check for the handle model.
|
||||
#
|
||||
# ASAN's leak detector is not usable here — CPython's arenas and OCCT's
|
||||
# process-lifetime singletons produce noise that would drown a real finding —
|
||||
# so this run targets *safety*: use-after-free, double-free, buffer overflow.
|
||||
# That is exactly the failure mode a wrong ownership rule produces (a stale
|
||||
# TShape is what segfaulted a process-global face memo under upstream OCP).
|
||||
# Leak *growth* is checked separately, by the RSS assertion in test_handles.py.
|
||||
set -euo pipefail
|
||||
|
||||
cd /io
|
||||
. scripts/_env.sh
|
||||
|
||||
BUILD="$CACHE/asan"
|
||||
mkdir -p "$BUILD"
|
||||
|
||||
echo "--- configuring ASAN build ---"
|
||||
cmake -G Ninja -S . -B "$BUILD" \
|
||||
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
|
||||
-DCMAKE_CXX_FLAGS="-fsanitize=address -fno-omit-frame-pointer -g" \
|
||||
-DCMAKE_SHARED_LINKER_FLAGS="-fsanitize=address" \
|
||||
-DPython_EXECUTABLE="$PY" \
|
||||
-Dnanobind_DIR="$("$PY" -c 'import nanobind; print(nanobind.cmake_dir())')" \
|
||||
>/dev/null
|
||||
|
||||
cmake --build "$BUILD" -j"$(nproc)"
|
||||
|
||||
STAGE=$(mktemp -d)
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
cp -r python/OCP python/n3xd_ocp tests "$STAGE/"
|
||||
cp "$BUILD"/_OCP*.so "$STAGE/OCP/"
|
||||
cp -r tests/data "$STAGE/tests/" 2>/dev/null || true
|
||||
|
||||
echo "--- running handle tests under ASAN ---"
|
||||
cd "$STAGE"
|
||||
# libasan.so is a linker script, not an ELF object — preloading it is silently
|
||||
# ignored and the run would look clean while ASAN was never active. Resolve
|
||||
# the real soname instead, and fail loudly if it is missing.
|
||||
ASAN_LIB=$(gcc -print-file-name=libasan.so.8)
|
||||
[ -f "$ASAN_LIB" ] || { echo "libasan.so.8 not found ($ASAN_LIB)" >&2; exit 1; }
|
||||
head -c 4 "$ASAN_LIB" | grep -q ELF || { echo "$ASAN_LIB is not an ELF object" >&2; exit 1; }
|
||||
|
||||
LD_PRELOAD="$ASAN_LIB" \
|
||||
N3XD_OCP_ASAN=1 \
|
||||
ASAN_OPTIONS="detect_leaks=0:abort_on_error=1:strict_string_checks=1:detect_stack_use_after_return=1" \
|
||||
"$PY" -m pytest tests/test_handles.py tests/test_shape_identity.py -q
|
||||
|
||||
echo "ASAN: no memory-safety errors"
|
||||
41
scripts/build_wheel.sh
Executable file
41
scripts/build_wheel.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the wheel. Runs INSIDE the OCCT builder image (see ../Makefile), which
|
||||
# supplies the kernel at /opt/occt and cp312 at /opt/python.
|
||||
set -euo pipefail
|
||||
|
||||
cd /io
|
||||
. scripts/_env.sh
|
||||
|
||||
echo "--- build (pass 1: compile) ---"
|
||||
rm -rf dist wheelhouse
|
||||
mkdir -p wheelhouse
|
||||
# --no-isolation keeps the build in the cached venv so the CMake tree and
|
||||
# ccache are actually reused between runs.
|
||||
"$PY" -m build --wheel --no-isolation --outdir dist
|
||||
|
||||
# Stubs are generated by importing the freshly built extension, so they cannot
|
||||
# exist before the first compile — and the wheel is packed from the source
|
||||
# tree, so they would miss this wheel and ship one build stale. Pass 2 repacks
|
||||
# with them present; it is incremental (same CMake tree, warm ccache).
|
||||
echo "--- stubs ---"
|
||||
scripts/stubgen.sh
|
||||
|
||||
echo "--- build (pass 2: repack with stubs) ---"
|
||||
rm -rf dist
|
||||
"$PY" -m build --wheel --no-isolation --outdir dist
|
||||
|
||||
echo "--- auditwheel repair ---"
|
||||
auditwheel repair --plat manylinux_2_28_x86_64 -w wheelhouse dist/*.whl
|
||||
|
||||
echo "--- smoke test (no LD_LIBRARY_PATH: proves the wheel is self-contained) ---"
|
||||
rm -rf "$CACHE/smoke"
|
||||
"$PY" -m venv "$CACHE/smoke"
|
||||
"$CACHE/smoke/bin/pip" install -q wheelhouse/*.whl
|
||||
env -u LD_LIBRARY_PATH "$CACHE/smoke/bin/python" -c "
|
||||
import OCP, n3xd_ocp
|
||||
from OCP.TopoDS import TopoDS_Shape
|
||||
print('OCP', OCP.__version__, '/ OCCT', OCP.__occt_version__)
|
||||
print('modules:', ' '.join(OCP._OCP.__all_modules__))
|
||||
"
|
||||
|
||||
ls -la wheelhouse/
|
||||
16
scripts/in_container_test.sh
Executable file
16
scripts/in_container_test.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# Inner development loop: incremental compile, then the test suite.
|
||||
#
|
||||
# Installs into the cached venv rather than building a wheel, so a one-file
|
||||
# change is a recompile plus a relink.
|
||||
set -euo pipefail
|
||||
|
||||
cd /io
|
||||
. scripts/_env.sh
|
||||
|
||||
"$PY" -m pip install -q --no-build-isolation --no-deps -e . 2>&1 | tail -5 || {
|
||||
echo "editable install failed; falling back to a plain install" >&2
|
||||
"$PY" -m pip install -q --no-build-isolation --no-deps --force-reinstall .
|
||||
}
|
||||
|
||||
"$PY" -m pytest tests -q "$@"
|
||||
39
scripts/stubgen.sh
Executable file
39
scripts/stubgen.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerate type stubs from the built extension.
|
||||
#
|
||||
# Must run where the OCCT shared libraries resolve (inside the builder image,
|
||||
# pre-auditwheel), since stubgen imports the module to introspect it. Shipping
|
||||
# these plus py.typed is what retires the backend's blanket
|
||||
# `unresolved-import = "ignore"` ty suppression.
|
||||
set -euo pipefail
|
||||
|
||||
cd /io
|
||||
. scripts/_env.sh
|
||||
|
||||
SO=$(find "$CACHE/skbuild" -name '_OCP*.so' 2>/dev/null | head -1)
|
||||
if [ -z "$SO" ]; then
|
||||
echo "no built extension found; run the wheel build first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stage the package tree next to the freshly built extension so stubgen can
|
||||
# import it without touching the repo copy.
|
||||
STAGE=$(mktemp -d)
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
cp -r python/OCP python/n3xd_ocp "$STAGE/"
|
||||
cp "$SO" "$STAGE/OCP/"
|
||||
|
||||
# -O <pkg> keeps each package's stubs inside its own directory: recursive
|
||||
# stubgen names files after the submodule alone, so a shared output dir would
|
||||
# flatten OCP.gp and n3xd_ocp.bintools into the same namespace.
|
||||
(cd "$STAGE" && "$PY" -m nanobind.stubgen -m OCP -r -O OCP && \
|
||||
"$PY" -m nanobind.stubgen -m n3xd_ocp -r -O n3xd_ocp)
|
||||
|
||||
find "$STAGE" -name '*.pyi' | while read -r f; do
|
||||
rel="${f#"$STAGE"/}"
|
||||
mkdir -p "python/$(dirname "$rel")"
|
||||
cp "$f" "python/$rel"
|
||||
done
|
||||
|
||||
echo "stubs written:"
|
||||
find python -name '*.pyi' | sort
|
||||
109
src/common/occt_exceptions.cpp
Normal file
109
src/common/occt_exceptions.cpp
Normal file
@@ -0,0 +1,109 @@
|
||||
#include "occt_exceptions.h"
|
||||
#include "occt_module.h"
|
||||
|
||||
#include <Standard_ConstructionError.hxx>
|
||||
#include <Standard_DimensionError.hxx>
|
||||
#include <Standard_DimensionMismatch.hxx>
|
||||
#include <Standard_DivideByZero.hxx>
|
||||
#include <Standard_DomainError.hxx>
|
||||
#include <Standard_Failure.hxx>
|
||||
#include <Standard_ImmutableObject.hxx>
|
||||
#include <Standard_NoSuchObject.hxx>
|
||||
#include <Standard_NotImplemented.hxx>
|
||||
#include <Standard_NullObject.hxx>
|
||||
#include <Standard_NullValue.hxx>
|
||||
#include <Standard_NumericError.hxx>
|
||||
#include <Standard_OutOfRange.hxx>
|
||||
#include <Standard_Overflow.hxx>
|
||||
#include <Standard_ProgramError.hxx>
|
||||
#include <Standard_RangeError.hxx>
|
||||
#include <Standard_TypeMismatch.hxx>
|
||||
#include <Standard_Underflow.hxx>
|
||||
#include <StdFail_NotDone.hxx>
|
||||
#include <StdFail_UndefinedDerivative.hxx>
|
||||
#include <StdFail_UndefinedValue.hxx>
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace {
|
||||
|
||||
/// OCCT class name -> Python exception type. Populated at import; read-only
|
||||
/// afterwards, so it is fork-safe and needs no lock.
|
||||
std::unordered_map<std::string, PyObject *> g_exc_types;
|
||||
PyObject *g_base = nullptr; // OCP.Standard.Standard_Failure
|
||||
|
||||
PyObject *make_exc(nb::module_ &mod, const char *qualified, const char *name,
|
||||
PyObject *base) {
|
||||
PyObject *exc = PyErr_NewException(qualified, base, nullptr);
|
||||
if (!exc)
|
||||
throw nb::python_error();
|
||||
mod.attr(name) = nb::borrow(exc);
|
||||
g_exc_types.emplace(name, exc);
|
||||
return exc;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void register_occt_exceptions(nb::module_ &root) {
|
||||
nb::module_ std_mod = ocp_submodule(root, "Standard");
|
||||
nb::module_ fail_mod = ocp_submodule(root, "StdFail");
|
||||
|
||||
// Standard_Failure derives RuntimeError: that single fact is what keeps
|
||||
// the backend's `except RuntimeError` sites working unchanged.
|
||||
g_base = make_exc(std_mod, "OCP.Standard.Standard_Failure",
|
||||
"Standard_Failure", PyExc_RuntimeError);
|
||||
|
||||
auto sub = [&](nb::module_ &m, const char *qualified, const char *name) {
|
||||
return make_exc(m, qualified, name, g_base);
|
||||
};
|
||||
|
||||
#define OCP_EXC(modvar, modname, name) \
|
||||
sub(modvar, "OCP." #modname "." #name, #name)
|
||||
OCP_EXC(std_mod, Standard, Standard_DomainError);
|
||||
OCP_EXC(std_mod, Standard, Standard_RangeError);
|
||||
OCP_EXC(std_mod, Standard, Standard_OutOfRange);
|
||||
OCP_EXC(std_mod, Standard, Standard_NoSuchObject);
|
||||
OCP_EXC(std_mod, Standard, Standard_TypeMismatch);
|
||||
OCP_EXC(std_mod, Standard, Standard_NullObject);
|
||||
OCP_EXC(std_mod, Standard, Standard_NullValue);
|
||||
OCP_EXC(std_mod, Standard, Standard_ConstructionError);
|
||||
OCP_EXC(std_mod, Standard, Standard_DimensionError);
|
||||
OCP_EXC(std_mod, Standard, Standard_DimensionMismatch);
|
||||
OCP_EXC(std_mod, Standard, Standard_NumericError);
|
||||
OCP_EXC(std_mod, Standard, Standard_DivideByZero);
|
||||
OCP_EXC(std_mod, Standard, Standard_Overflow);
|
||||
OCP_EXC(std_mod, Standard, Standard_Underflow);
|
||||
OCP_EXC(std_mod, Standard, Standard_ProgramError);
|
||||
OCP_EXC(std_mod, Standard, Standard_NotImplemented);
|
||||
OCP_EXC(std_mod, Standard, Standard_ImmutableObject);
|
||||
OCP_EXC(fail_mod, StdFail, StdFail_NotDone);
|
||||
OCP_EXC(fail_mod, StdFail, StdFail_UndefinedDerivative);
|
||||
OCP_EXC(fail_mod, StdFail, StdFail_UndefinedValue);
|
||||
#undef OCP_EXC
|
||||
|
||||
nb::register_exception_translator(
|
||||
[](const std::exception_ptr &p, void *) {
|
||||
try {
|
||||
std::rethrow_exception(p);
|
||||
} catch (const Standard_Failure &e) {
|
||||
// Dispatch on the dynamic OCCT type so the Python type name
|
||||
// matches what the kernel actually threw; anything we did not
|
||||
// bind falls back to the RuntimeError-derived base.
|
||||
PyObject *type = g_base;
|
||||
const char *name = e.DynamicType()->Name();
|
||||
if (name) {
|
||||
auto it = g_exc_types.find(name);
|
||||
if (it != g_exc_types.end())
|
||||
type = it->second;
|
||||
}
|
||||
const char *msg = e.GetMessageString();
|
||||
std::string text = std::string(name ? name : "Standard_Failure");
|
||||
if (msg && *msg)
|
||||
text = std::string(msg);
|
||||
PyErr_SetString(type, text.c_str());
|
||||
}
|
||||
},
|
||||
nullptr);
|
||||
}
|
||||
20
src/common/occt_exceptions.h
Normal file
20
src/common/occt_exceptions.h
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
OCCT Standard_Failure -> Python exception translation.
|
||||
|
||||
The backend's discipline is `IsDone()` gates (47 sites) plus `except
|
||||
RuntimeError` (~10 sites); it never names an OCCT exception class. So the
|
||||
hard requirement is that every kernel failure arrives as something deriving
|
||||
RuntimeError. The concrete type names matter too, but for a subtler
|
||||
reason: cad_pool's worker children marshal failures home as
|
||||
f"{type(exc).__name__}: {exc}" strings, so the name is observable output.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <nanobind/nanobind.h>
|
||||
|
||||
namespace nb = nanobind;
|
||||
|
||||
/// Create the OCP.Standard / OCP.StdFail exception classes and install the
|
||||
/// translator. Call once, before any module that can raise.
|
||||
void register_occt_exceptions(nb::module_ &root);
|
||||
124
src/common/occt_handle.h
Normal file
124
src/common/occt_handle.h
Normal file
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
Type caster for opencascade::handle<T> — the ownership core of this binding.
|
||||
|
||||
OCCT transients carry their own intrusive, atomic reference count
|
||||
(Standard_Transient), so a handle keeps an object alive entirely on the C++
|
||||
side. That is what lets us release the GIL around kernel calls: OCCT may
|
||||
copy handles on its own worker threads without ever touching Python.
|
||||
|
||||
The design mirrors nanobind's own stl/shared_ptr.h caster:
|
||||
|
||||
C++ -> Python the wrapper is a *non-owning* nanobind instance pointing
|
||||
at the C++ object, plus one handle stored in the object's
|
||||
keep-alive list. Python holds exactly one OCCT reference
|
||||
per wrapper, released at deallocation. An existing
|
||||
wrapper is reused (is_new == false), so `a is b` holds for
|
||||
as long as a wrapper stays alive.
|
||||
|
||||
Python -> C++ a plain handle copy (one incref), balanced when the caster
|
||||
dies after the call. Unlike shared_ptr we do *not* need
|
||||
to keep the PyObject alive: the OCCT refcount, not the
|
||||
Python instance, owns the object's memory.
|
||||
|
||||
That last sentence is only true if every wrapped transient was heap
|
||||
allocated and is handle-owned. See occt_transient.h — transient
|
||||
constructors are bound through OCP_TRANSIENT_NEW, never nb::init<>, so
|
||||
Python never owns transient storage. from_python re-checks the invariant
|
||||
rather than trusting it, because the failure mode is a double free.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <nanobind/nanobind.h>
|
||||
|
||||
#include <Standard_Handle.hxx>
|
||||
#include <Standard_Transient.hxx>
|
||||
|
||||
#include <type_traits>
|
||||
|
||||
NAMESPACE_BEGIN(NB_NAMESPACE)
|
||||
NAMESPACE_BEGIN(detail)
|
||||
|
||||
// Marked NB_NOINLINE so the (identical) body is not duplicated into every
|
||||
// instantiation of the caster below — there is one per bound transient class.
|
||||
inline NB_NOINLINE void
|
||||
occt_handle_keep_alive(opencascade::handle<Standard_Transient> &&h,
|
||||
PyObject *o) noexcept {
|
||||
keep_alive(o, new opencascade::handle<Standard_Transient>(std::move(h)),
|
||||
[](void *p) noexcept {
|
||||
delete (opencascade::handle<Standard_Transient> *) p;
|
||||
});
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
struct type_caster<
|
||||
opencascade::handle<T>,
|
||||
enable_if_t<std::is_base_of_v<Standard_Transient, std::decay_t<T>>>> {
|
||||
static constexpr bool IsClass = true;
|
||||
using Caster = make_caster<T>;
|
||||
using Td = std::decay_t<T>;
|
||||
|
||||
NB_TYPE_CASTER(opencascade::handle<T>, Caster::Name)
|
||||
|
||||
static_assert(is_base_caster_v<Caster>,
|
||||
"Conversion of opencascade::handle<T> requires that T is "
|
||||
"bound through nanobind's regular class mechanism.");
|
||||
|
||||
bool from_python(handle src, uint8_t flags,
|
||||
cleanup_list *cleanup) noexcept {
|
||||
// A null handle is OCCT's absent value and maps to None in both
|
||||
// directions; several APIs (BRep_Tool::Surface on a degenerate edge,
|
||||
// for one) legitimately return one.
|
||||
if (src.is_none()) {
|
||||
value = Value();
|
||||
return true;
|
||||
}
|
||||
|
||||
flags &= ~((uint8_t) cast_flags::convert);
|
||||
|
||||
Caster caster;
|
||||
if (!caster.from_python(src, flags, cleanup))
|
||||
return false;
|
||||
|
||||
Td *ptr = caster.operator Td *();
|
||||
|
||||
// Invariant check, not defensive programming: a refcount of zero means
|
||||
// this wrapper owns its storage (nb::init<>, or a by-value return),
|
||||
// and taking a handle to it would hand OCCT the right to `delete` a
|
||||
// nanobind instance's memory. Refuse instead — a TypeError beats a
|
||||
// heap corruption, and it can only be reached by a binding bug.
|
||||
if (ptr && ptr->GetRefCount() == 0) {
|
||||
assert(!"OCCT transient is not handle-owned (see occt_transient.h)");
|
||||
return false;
|
||||
}
|
||||
|
||||
value = Value(ptr);
|
||||
return true;
|
||||
}
|
||||
|
||||
static handle from_cpp(const Value &v, rv_policy,
|
||||
cleanup_list *cleanup) noexcept {
|
||||
Td *ptr = v.get();
|
||||
if (!ptr)
|
||||
return none().release();
|
||||
|
||||
// Transients are polymorphic, so nb_type_put_p downcasts on the
|
||||
// dynamic type: BRep_Tool::Surface returning a Geom_Surface handle to
|
||||
// a plane arrives in Python as Geom_Plane when that class is bound.
|
||||
bool is_new = false;
|
||||
handle result = nb_type_put_p(&typeid(Td), &typeid(*ptr), (void *) ptr,
|
||||
rv_policy::reference, cleanup, &is_new);
|
||||
|
||||
// Only a freshly created wrapper takes a reference. Attaching one per
|
||||
// conversion would pile up redundant handles on a long-lived object
|
||||
// that crosses the boundary many times.
|
||||
if (result.is_valid() && is_new)
|
||||
occt_handle_keep_alive(
|
||||
opencascade::handle<Standard_Transient>(ptr), result.ptr());
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
NAMESPACE_END(detail)
|
||||
NAMESPACE_END(NB_NAMESPACE)
|
||||
28
src/common/occt_module.cpp
Normal file
28
src/common/occt_module.cpp
Normal file
@@ -0,0 +1,28 @@
|
||||
#include "occt_module.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
std::vector<std::string> &ocp_module_registry() {
|
||||
static std::vector<std::string> registry;
|
||||
return registry;
|
||||
}
|
||||
|
||||
nb::module_ ocp_named_module(const char *full_name) {
|
||||
// Borrowed reference, and already interned in sys.modules — which is what
|
||||
// makes `import OCP.TopoDS` work without a .py shim per module. Note this
|
||||
// does not require the parent package to exist yet.
|
||||
PyObject *mod = PyImport_AddModule(full_name);
|
||||
if (!mod)
|
||||
throw nb::python_error();
|
||||
return nb::borrow<nb::module_>(mod);
|
||||
}
|
||||
|
||||
nb::module_ ocp_submodule(nb::module_ &parent, const char *name) {
|
||||
nb::module_ m = ocp_named_module((std::string("OCP.") + name).c_str());
|
||||
parent.attr(name) = m;
|
||||
|
||||
auto ® = ocp_module_registry();
|
||||
if (std::find(reg.begin(), reg.end(), name) == reg.end())
|
||||
reg.emplace_back(name);
|
||||
return m;
|
||||
}
|
||||
49
src/common/occt_module.h
Normal file
49
src/common/occt_module.h
Normal file
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
Submodule and static-method helpers.
|
||||
|
||||
The wheel ships one extension, OCP/_OCP, which registers the OCP.*
|
||||
submodules itself. They are created with PyImport_AddModule so they are
|
||||
real entries in sys.modules: `import OCP.TopoDS`, `from OCP.TopoDS import
|
||||
TopoDS_Shape` and cls.__module__ == "OCP.TopoDS" all work without a shim
|
||||
module per name. (nanobind has no def_submodule.)
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <nanobind/nanobind.h>
|
||||
|
||||
// Included here, not per module, so no translation unit can accidentally bind
|
||||
// a handle-returning API without the caster in scope: the symptom is a silent
|
||||
// fallback that reports "unable to convert" only at call time.
|
||||
#include "occt_handle.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace nb = nanobind;
|
||||
|
||||
// Every module file wants "name"_a for keyword arguments.
|
||||
using namespace nb::literals;
|
||||
|
||||
/// Names of every OCP.* submodule registered so far, in registration order.
|
||||
/// OCP/__init__.py re-exports these so `OCP.TopoDS` resolves as an attribute
|
||||
/// too, not only as an import.
|
||||
std::vector<std::string> &ocp_module_registry();
|
||||
|
||||
/// Create (or fetch) a real module by its full dotted name. Idempotent, and
|
||||
/// deliberately does not require the parent package to exist — n3xd_ocp's
|
||||
/// submodules are registered this way so that the Python package's __init__
|
||||
/// still runs whichever import happens first.
|
||||
nb::module_ ocp_named_module(const char *full_name);
|
||||
|
||||
/// Create (or fetch) "OCP.<name>", attach it to `parent`, and record it for
|
||||
/// OCP/__init__.py's re-export loop.
|
||||
nb::module_ ocp_submodule(nb::module_ &parent, const char *name);
|
||||
|
||||
/// Bind an OCCT static method under the `_s` suffix.
|
||||
///
|
||||
/// Upstream OCP suffixes every static it exposes, and the app calls 176 of
|
||||
/// them by that name (BRepGProp.SurfaceProperties_s, TopExp.MapShapes_s,
|
||||
/// BinTools.Write_s, ...). The rule here is blanket rather than clash-driven,
|
||||
/// so no static can accidentally ship without it.
|
||||
#define OCP_DEF_S(cls, name, ...) (cls).def_static(name "_s", __VA_ARGS__)
|
||||
35
src/common/occt_policies.h
Normal file
35
src/common/occt_policies.h
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
Return-value and GIL policies.
|
||||
|
||||
Two rules live here, and both close a defect class the app currently works
|
||||
around in Python:
|
||||
|
||||
1. Sub-shapes come back BY VALUE. A TopoDS_Shape is a small value holding
|
||||
a handle to its TShape, so copying it is one incref and the copy owns
|
||||
what it points at. Returning a reference into a container (an explorer's
|
||||
Current(), a map's FindKey(), a BOP history list) instead lets the
|
||||
wrapper outlive its owner — that is what segfaulted a process-global
|
||||
face memo under upstream OCP, and why cad/topology/geom_memo.py is
|
||||
scoped to a single rebuild by a ContextVar.
|
||||
|
||||
nanobind copies lvalue references by default; OCP_RETURN_COPY states it
|
||||
explicitly so a reviewer can see the guarantee at the call site.
|
||||
|
||||
2. The GIL is released around calls that stay inside the kernel. The list
|
||||
is deliberate rather than blanket: a call that can re-enter Python (the
|
||||
BinTools stream bridge, notably) must keep it.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <nanobind/nanobind.h>
|
||||
|
||||
namespace nb = nanobind;
|
||||
|
||||
/// Explicit "returns an owned copy" policy for shape-valued returns.
|
||||
#define OCP_RETURN_COPY nb::rv_policy::copy
|
||||
|
||||
/// Release the GIL for the duration of a kernel call. Only for calls that
|
||||
/// cannot re-enter the interpreter: Build/Perform, meshing, BRepCheck,
|
||||
/// file-based readers and writers.
|
||||
#define OCP_NOGIL nb::call_guard<nb::gil_scoped_release>()
|
||||
41
src/common/occt_stream.h
Normal file
41
src/common/occt_stream.h
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
Python file-like <-> std::stream adaptation for BinTools.
|
||||
|
||||
Upstream's signature takes an io.BytesIO, and the app always passes one
|
||||
(cad_pool and derive.py serialise shapes through BytesIO). Rather than
|
||||
bridge a streambuf that calls back into Python on every chunk, this slurps:
|
||||
read the whole payload first, then hand the kernel a pure C++ stream.
|
||||
|
||||
Two things fall out of that. It is correct no matter how BinTools seeks
|
||||
inside the stream, and the kernel half touches no Python at all — so the
|
||||
GIL can be released around it, which a callback bridge could not do. The
|
||||
cost is one extra copy of the BREP bytes, which n3xd_ocp.bintools avoids
|
||||
entirely for the pool paths that care.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <nanobind/nanobind.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace nb = nanobind;
|
||||
|
||||
/// Read a Python file-like object to end of stream.
|
||||
inline std::string ocp_slurp(nb::handle stream) {
|
||||
nb::object data = nb::getattr(stream, "read")();
|
||||
char *buf = nullptr;
|
||||
Py_ssize_t size = 0;
|
||||
if (PyBytes_AsStringAndSize(data.ptr(), &buf, &size) != 0)
|
||||
throw nb::python_error();
|
||||
return std::string(buf, (size_t) size);
|
||||
}
|
||||
|
||||
/// Write a byte payload to a Python file-like object.
|
||||
inline void ocp_spit(nb::handle stream, const std::string &data) {
|
||||
nb::object payload = nb::steal(
|
||||
PyBytes_FromStringAndSize(data.data(), (Py_ssize_t) data.size()));
|
||||
if (!payload.is_valid())
|
||||
throw nb::python_error();
|
||||
nb::getattr(stream, "write")(payload);
|
||||
}
|
||||
42
src/common/occt_transient.h
Normal file
42
src/common/occt_transient.h
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
Binding helpers for OCCT transient (handle-managed) classes.
|
||||
|
||||
Rule: a transient class never gets nb::init<>. nanobind's normal
|
||||
constructor placement-news the object into the Python instance's own
|
||||
storage, which OCCT would later try to `delete` when the last handle goes
|
||||
away. The app hits this on a real path — sketch_builder/edges.py builds a
|
||||
Geom_BSplineCurve in Python and hands it to BRepBuilderAPI_MakeEdge, which
|
||||
stores a handle to it that outlives the call.
|
||||
|
||||
So constructors are bound through nb::new_ returning a handle: the object
|
||||
is heap allocated and reaches Python through the handle caster, owning its
|
||||
own storage from birth. occt_handle.h's from_python check enforces this.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <nanobind/nanobind.h>
|
||||
|
||||
#include <Standard_Handle.hxx>
|
||||
#include <Standard_Transient.hxx>
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace nb = nanobind;
|
||||
|
||||
/// Declare a bound transient class. Bind its constructors with ocp_new<T,
|
||||
/// Args...>() — nb::init<> on such a class is a bug (see above).
|
||||
template <typename T, typename... Bases>
|
||||
nb::class_<T, Bases...> ocp_transient_class(nb::handle scope, const char *name) {
|
||||
return nb::class_<T, Bases...>(scope, name);
|
||||
}
|
||||
|
||||
/// Heap-allocating constructor for a transient class:
|
||||
///
|
||||
/// cls.def(ocp_new<Poly_Triangulation, int, int, bool>(),
|
||||
/// "nbNodes"_a, "nbTriangles"_a, "hasUVNodes"_a);
|
||||
template <typename T, typename... Args> auto ocp_new() {
|
||||
return nb::new_([](Args... args) {
|
||||
return opencascade::handle<T>(new T(std::forward<Args>(args)...));
|
||||
});
|
||||
}
|
||||
59
src/core.cpp
Normal file
59
src/core.cpp
Normal file
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
OCP._OCP — the single extension module.
|
||||
|
||||
One .so registers every OCP.* submodule rather than shipping one extension
|
||||
per module: the whole surface traffics in TopoDS_Shape, gp_* and handles,
|
||||
so sharing types in-process is free here and would otherwise depend on
|
||||
nanobind's cross-extension registry; registration order stays an explicit
|
||||
sequence below instead of an import-order puzzle; and cad_pool's forkserver
|
||||
warms exactly one dlopen. Per-module .cpp files keep incremental compiles
|
||||
cheap — only the final link is shared.
|
||||
*/
|
||||
|
||||
#include <nanobind/nanobind.h>
|
||||
#include <nanobind/stl/string.h>
|
||||
#include <nanobind/stl/vector.h>
|
||||
|
||||
#include "common/occt_exceptions.h"
|
||||
#include "common/occt_module.h"
|
||||
|
||||
#include <Standard_Version.hxx>
|
||||
|
||||
namespace nb = nanobind;
|
||||
|
||||
void register_Standard(nb::module_ &);
|
||||
void register_gp(nb::module_ &);
|
||||
void register_TopAbs(nb::module_ &);
|
||||
void register_TopLoc(nb::module_ &);
|
||||
void register_TopoDS(nb::module_ &);
|
||||
void register_TopTools(nb::module_ &);
|
||||
void register_TopExp(nb::module_ &);
|
||||
void register_Poly(nb::module_ &);
|
||||
void register_BRep(nb::module_ &);
|
||||
void register_BinTools(nb::module_ &);
|
||||
void register_ext(nb::module_ &);
|
||||
|
||||
NB_MODULE(_OCP, m) {
|
||||
m.doc() = "nanobind bindings for the OpenCASCADE geometry kernel";
|
||||
|
||||
// Exceptions first: they create OCP.Standard and OCP.StdFail, and any
|
||||
// module registered afterwards may raise through the translator.
|
||||
register_occt_exceptions(m);
|
||||
|
||||
register_Standard(m);
|
||||
register_gp(m);
|
||||
register_TopAbs(m);
|
||||
register_TopLoc(m);
|
||||
register_TopoDS(m);
|
||||
register_TopTools(m);
|
||||
register_TopExp(m);
|
||||
register_Poly(m);
|
||||
register_BRep(m);
|
||||
register_BinTools(m);
|
||||
|
||||
register_ext(m);
|
||||
|
||||
m.attr("__all_modules__") = ocp_module_registry();
|
||||
m.attr("__occt_version__") = OCC_VERSION_COMPLETE;
|
||||
m.attr("__version__") = N3XD_OCP_VERSION;
|
||||
}
|
||||
53
src/ext/ext_bintools.cpp
Normal file
53
src/ext/ext_bintools.cpp
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
n3xd_ocp.bintools — shape <-> bytes without the file-object detour.
|
||||
|
||||
The pools move every shape as BinTools bytes through a BytesIO purely
|
||||
because that is the shape of upstream's API. Here the payload is a bytes
|
||||
object directly: one buffer instead of a BytesIO plus its internal copy,
|
||||
and the kernel half runs with the GIL released.
|
||||
|
||||
Byte-for-byte identical to OCP.BinTools output — same BinTools::Write, same
|
||||
format version — so it is interchangeable with it, including for the
|
||||
content-addressed derive payloads.
|
||||
*/
|
||||
|
||||
#include "../common/occt_module.h"
|
||||
|
||||
#include <nanobind/stl/string.h>
|
||||
|
||||
#include <BinTools.hxx>
|
||||
#include <TopoDS_Shape.hxx>
|
||||
|
||||
#include <sstream>
|
||||
|
||||
void register_ext_bintools() {
|
||||
nb::module_ m = ocp_named_module("n3xd_ocp.bintools");
|
||||
|
||||
m.def(
|
||||
"write_bytes",
|
||||
[](const TopoDS_Shape &shape) {
|
||||
std::ostringstream out;
|
||||
{
|
||||
nb::gil_scoped_release nogil;
|
||||
BinTools::Write(shape, out);
|
||||
}
|
||||
std::string data = out.str();
|
||||
return nb::bytes(data.data(), data.size());
|
||||
},
|
||||
"shape"_a,
|
||||
"Serialise a shape to BREP bytes (identical to OCP.BinTools.Write_s).");
|
||||
|
||||
m.def(
|
||||
"read_bytes",
|
||||
[](nb::bytes data) {
|
||||
std::string buf(data.c_str(), data.size());
|
||||
TopoDS_Shape shape;
|
||||
{
|
||||
nb::gil_scoped_release nogil;
|
||||
std::istringstream in(buf);
|
||||
BinTools::Read(shape, in);
|
||||
}
|
||||
return shape;
|
||||
},
|
||||
"data"_a, "Deserialise BREP bytes into a shape.");
|
||||
}
|
||||
25
src/ext/ext_core.cpp
Normal file
25
src/ext/ext_core.cpp
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
n3xd_ocp — the extension surface, kept out of the OCP namespace on purpose.
|
||||
|
||||
OCP.* stays a drop-in replacement for cadquery-ocp-novtk, symbol for symbol,
|
||||
so parity testing means something. Anything that goes *beyond* upstream —
|
||||
bulk array extraction, batched measurement, GIL-free serialisation — lives
|
||||
here instead, in the same wheel. The backend adopts these after the
|
||||
cutover, one call site at a time.
|
||||
|
||||
Only the leaf modules are registered here. Creating the `n3xd_ocp` parent
|
||||
from C++ would put a bare module in sys.modules, and a later `import
|
||||
n3xd_ocp` would then find it and never run the package's __init__.py.
|
||||
*/
|
||||
|
||||
#include "../common/occt_module.h"
|
||||
|
||||
namespace nb = nanobind;
|
||||
|
||||
void register_ext_bintools();
|
||||
void register_ext_debug();
|
||||
|
||||
void register_ext(nb::module_ &) {
|
||||
register_ext_bintools();
|
||||
register_ext_debug();
|
||||
}
|
||||
78
src/ext/ext_debug.cpp
Normal file
78
src/ext/ext_debug.cpp
Normal file
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
n3xd_ocp._debug — introspection the handle-model tests need.
|
||||
|
||||
Not part of the supported surface; it exists so tests can assert ownership
|
||||
invariants (OCCT refcounts, wrapper identity) that are otherwise invisible
|
||||
from Python.
|
||||
*/
|
||||
|
||||
#include "../common/occt_handle.h"
|
||||
#include "../common/occt_module.h"
|
||||
|
||||
#include <Poly_Triangulation.hxx>
|
||||
#include <Standard_Transient.hxx>
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
/// Holds handles alive on the C++ side so a test can drop every Python
|
||||
/// reference and check the object survived.
|
||||
std::vector<opencascade::handle<Standard_Transient>> &cpp_holds() {
|
||||
static std::vector<opencascade::handle<Standard_Transient>> held;
|
||||
return held;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void register_ext_debug() {
|
||||
nb::module_ m = ocp_named_module("n3xd_ocp._debug");
|
||||
|
||||
m.def(
|
||||
"refcount",
|
||||
[](const opencascade::handle<Standard_Transient> &h) {
|
||||
// The caster's own handle is alive for the duration of the call,
|
||||
// so subtract it to report what the caller actually holds.
|
||||
return h.IsNull() ? 0 : h->GetRefCount() - 1;
|
||||
},
|
||||
nb::arg("obj").none(),
|
||||
"OCCT reference count excluding this call's own handle.");
|
||||
|
||||
m.def(
|
||||
"roundtrip",
|
||||
[](const opencascade::handle<Standard_Transient> &h) { return h; },
|
||||
nb::arg("obj").none(),
|
||||
"Send a transient through C++ and back — must return the same wrapper.");
|
||||
|
||||
m.def(
|
||||
"hold",
|
||||
[](const opencascade::handle<Standard_Transient> &h) {
|
||||
cpp_holds().push_back(h);
|
||||
return (int) cpp_holds().size() - 1;
|
||||
},
|
||||
"obj"_a, "Keep a C++-side reference; returns its slot index.");
|
||||
|
||||
m.def(
|
||||
"release_held", []() { cpp_holds().clear(); },
|
||||
"Drop every C++-side reference taken by hold().");
|
||||
|
||||
m.def(
|
||||
"held_is_alive",
|
||||
[](int slot) {
|
||||
auto &held = cpp_holds();
|
||||
return slot >= 0 && slot < (int) held.size() &&
|
||||
!held[(size_t) slot].IsNull();
|
||||
},
|
||||
"slot"_a);
|
||||
|
||||
m.def(
|
||||
"held_nb_nodes",
|
||||
[](int slot) {
|
||||
auto &held = cpp_holds();
|
||||
opencascade::handle<Poly_Triangulation> tri =
|
||||
opencascade::handle<Poly_Triangulation>::DownCast(
|
||||
held[(size_t) slot]);
|
||||
return tri.IsNull() ? -1 : tri->NbNodes();
|
||||
},
|
||||
"slot"_a, "Touch a held object's memory — catches a premature free.");
|
||||
}
|
||||
73
src/modules/mod_BRep.cpp
Normal file
73
src/modules/mod_BRep.cpp
Normal file
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
OCP.BRep — builder plus the Inc 0 half of BRep_Tool.
|
||||
|
||||
BRep_Tool::Surface / Curve return Geom handles and land with Inc 1, where
|
||||
the Geom classes arrive; Triangulation is here because it is what the
|
||||
tessellation path reads and what the handle spike exercises.
|
||||
*/
|
||||
|
||||
#include "../common/occt_module.h"
|
||||
#include "../common/occt_policies.h"
|
||||
|
||||
#include <BRep_Builder.hxx>
|
||||
#include <BRep_Tool.hxx>
|
||||
#include <Poly_Triangulation.hxx>
|
||||
#include <TopLoc_Location.hxx>
|
||||
#include <TopoDS_Compound.hxx>
|
||||
#include <TopoDS_Edge.hxx>
|
||||
#include <TopoDS_Face.hxx>
|
||||
#include <TopoDS_Shell.hxx>
|
||||
#include <TopoDS_Solid.hxx>
|
||||
#include <TopoDS_Vertex.hxx>
|
||||
#include <TopoDS_Wire.hxx>
|
||||
|
||||
void register_BRep(nb::module_ &root) {
|
||||
nb::module_ m = ocp_submodule(root, "BRep");
|
||||
|
||||
nb::class_<BRep_Builder>(m, "BRep_Builder")
|
||||
.def(nb::init<>())
|
||||
.def("MakeCompound", &BRep_Builder::MakeCompound, "C"_a)
|
||||
.def("MakeShell", &BRep_Builder::MakeShell, "S"_a)
|
||||
.def("MakeSolid", &BRep_Builder::MakeSolid, "S"_a)
|
||||
.def("MakeWire", &BRep_Builder::MakeWire, "W"_a)
|
||||
.def("Add",
|
||||
[](const BRep_Builder &self, TopoDS_Shape &S,
|
||||
const TopoDS_Shape &C) { self.Add(S, C); },
|
||||
"S"_a, "C"_a)
|
||||
.def("Remove",
|
||||
[](const BRep_Builder &self, TopoDS_Shape &S,
|
||||
const TopoDS_Shape &C) { self.Remove(S, C); },
|
||||
"S"_a, "C"_a);
|
||||
|
||||
nb::class_<BRep_Tool> cls(m, "BRep_Tool");
|
||||
|
||||
// The location is an in/out parameter, matching upstream and the five call
|
||||
// sites in the app (`BRep_Tool.Triangulation_s(face, loc)`): nanobind hands
|
||||
// the lambda a reference into the caller's own instance, so the assignment
|
||||
// is visible in Python. A face without a mesh yields a null handle, which
|
||||
// the caster maps to None.
|
||||
OCP_DEF_S(
|
||||
cls, "Triangulation",
|
||||
[](const TopoDS_Face &F, TopLoc_Location &L) {
|
||||
return BRep_Tool::Triangulation(F, L);
|
||||
},
|
||||
"F"_a, "L"_a);
|
||||
OCP_DEF_S(
|
||||
cls, "Pnt", [](const TopoDS_Vertex &V) { return BRep_Tool::Pnt(V); },
|
||||
"V"_a);
|
||||
OCP_DEF_S(
|
||||
cls, "Degenerated",
|
||||
[](const TopoDS_Edge &E) { return BRep_Tool::Degenerated(E); }, "E"_a);
|
||||
OCP_DEF_S(
|
||||
cls, "Tolerance",
|
||||
[](const TopoDS_Face &F) { return BRep_Tool::Tolerance(F); }, "F"_a);
|
||||
OCP_DEF_S(
|
||||
cls, "Tolerance",
|
||||
[](const TopoDS_Edge &E) { return BRep_Tool::Tolerance(E); }, "E"_a);
|
||||
OCP_DEF_S(
|
||||
cls, "Tolerance",
|
||||
[](const TopoDS_Vertex &V) { return BRep_Tool::Tolerance(V); }, "V"_a);
|
||||
OCP_DEF_S(
|
||||
cls, "IsClosed",
|
||||
[](const TopoDS_Shape &S) { return BRep_Tool::IsClosed(S); }, "S"_a);
|
||||
}
|
||||
51
src/modules/mod_BinTools.cpp
Normal file
51
src/modules/mod_BinTools.cpp
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
OCP.BinTools — the IPC-critical path.
|
||||
|
||||
Every subprocess pool serialises shapes through here, and derive.py content-
|
||||
addresses the result (payloads/derived/brep/<sha256>.brep) with the digest
|
||||
stored in the document. So byte-stability is a correctness requirement,
|
||||
not merely a round-trip one, and it is what the Inc 0 gate measures.
|
||||
|
||||
The GIL is released around the kernel half. That is possible because the
|
||||
Python file object is drained (or filled) on either side of the call rather
|
||||
than bridged through a streambuf that would call back into the interpreter
|
||||
mid-serialisation — see occt_stream.h.
|
||||
*/
|
||||
|
||||
#include "../common/occt_module.h"
|
||||
#include "../common/occt_stream.h"
|
||||
|
||||
#include <BinTools.hxx>
|
||||
#include <TopoDS_Shape.hxx>
|
||||
|
||||
#include <sstream>
|
||||
|
||||
void register_BinTools(nb::module_ &root) {
|
||||
nb::module_ m = ocp_submodule(root, "BinTools");
|
||||
|
||||
nb::class_<BinTools> cls(m, "BinTools");
|
||||
|
||||
// Matches upstream overload 1: writes with triangulation, at
|
||||
// BinTools_FormatVersion_CURRENT.
|
||||
OCP_DEF_S(
|
||||
cls, "Write",
|
||||
[](const TopoDS_Shape &theShape, nb::object theStream) {
|
||||
std::ostringstream out;
|
||||
{
|
||||
nb::gil_scoped_release nogil;
|
||||
BinTools::Write(theShape, out);
|
||||
}
|
||||
ocp_spit(theStream, out.str());
|
||||
},
|
||||
"theShape"_a, "theStream"_a);
|
||||
|
||||
OCP_DEF_S(
|
||||
cls, "Read",
|
||||
[](TopoDS_Shape &theShape, nb::object theStream) {
|
||||
std::string data = ocp_slurp(theStream);
|
||||
std::istringstream in(data);
|
||||
nb::gil_scoped_release nogil;
|
||||
BinTools::Read(theShape, in);
|
||||
},
|
||||
"theShape"_a, "theStream"_a);
|
||||
}
|
||||
56
src/modules/mod_Poly.cpp
Normal file
56
src/modules/mod_Poly.cpp
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
OCP.Poly — also the handle-model spike vehicle.
|
||||
|
||||
Poly_Triangulation is a transient (handle-managed) class that the app both
|
||||
receives from the kernel (BRep_Tool::Triangulation) and, in the wider
|
||||
surface, constructs. It is therefore the natural place to prove the
|
||||
ownership rules in occt_handle.h before the rest of the surface depends on
|
||||
them. Constructors go through ocp_new, never nb::init<>.
|
||||
*/
|
||||
|
||||
#include "../common/occt_module.h"
|
||||
#include "../common/occt_policies.h"
|
||||
#include "../common/occt_transient.h"
|
||||
|
||||
#include <Poly_Triangulation.hxx>
|
||||
|
||||
void register_Poly(nb::module_ &root) {
|
||||
nb::module_ m = ocp_submodule(root, "Poly");
|
||||
|
||||
nb::class_<Poly_Triangle>(m, "Poly_Triangle")
|
||||
.def(nb::init<>())
|
||||
.def(nb::init<Standard_Integer, Standard_Integer, Standard_Integer>(),
|
||||
"theN1"_a, "theN2"_a, "theN3"_a)
|
||||
.def("Value", &Poly_Triangle::Value, "theIndex"_a)
|
||||
.def("Get", [](const Poly_Triangle &t) {
|
||||
Standard_Integer n1 = 0, n2 = 0, n3 = 0;
|
||||
t.Get(n1, n2, n3);
|
||||
return nb::make_tuple(n1, n2, n3);
|
||||
});
|
||||
|
||||
nb::class_<Poly_Triangulation, Standard_Transient>(m, "Poly_Triangulation")
|
||||
.def(ocp_new<Poly_Triangulation>())
|
||||
.def(ocp_new<Poly_Triangulation, Standard_Integer, Standard_Integer,
|
||||
Standard_Boolean, Standard_Boolean>(),
|
||||
"theNbNodes"_a, "theNbTriangles"_a, "theHasUVNodes"_a,
|
||||
"theHasNormals"_a = Standard_False)
|
||||
.def("NbNodes", &Poly_Triangulation::NbNodes)
|
||||
.def("NbTriangles", &Poly_Triangulation::NbTriangles)
|
||||
.def("HasUVNodes", &Poly_Triangulation::HasUVNodes)
|
||||
.def("HasNormals", &Poly_Triangulation::HasNormals)
|
||||
.def("Node", &Poly_Triangulation::Node, "theIndex"_a, OCP_RETURN_COPY)
|
||||
.def("SetNode", &Poly_Triangulation::SetNode, "theIndex"_a,
|
||||
"thePnt"_a)
|
||||
.def("Triangle", &Poly_Triangulation::Triangle, "theIndex"_a,
|
||||
OCP_RETURN_COPY)
|
||||
.def("SetTriangle", &Poly_Triangulation::SetTriangle, "theIndex"_a,
|
||||
"theTriangle"_a)
|
||||
.def("Normal",
|
||||
nb::overload_cast<Standard_Integer>(&Poly_Triangulation::Normal,
|
||||
nb::const_),
|
||||
"theIndex"_a, OCP_RETURN_COPY)
|
||||
.def("UVNode", &Poly_Triangulation::UVNode, "theIndex"_a,
|
||||
OCP_RETURN_COPY)
|
||||
.def("Deflection",
|
||||
nb::overload_cast<>(&Poly_Triangulation::Deflection, nb::const_));
|
||||
}
|
||||
25
src/modules/mod_Standard.cpp
Normal file
25
src/modules/mod_Standard.cpp
Normal file
@@ -0,0 +1,25 @@
|
||||
#include "../common/occt_module.h"
|
||||
|
||||
#include <Standard_Transient.hxx>
|
||||
#include <Standard_Type.hxx>
|
||||
#include <Standard_Version.hxx>
|
||||
|
||||
void register_Standard(nb::module_ &root) {
|
||||
nb::module_ m = ocp_submodule(root, "Standard");
|
||||
|
||||
// Every handle-managed class derives from this, and nanobind needs it
|
||||
// registered before it can be named as a base. Deliberately not
|
||||
// constructible from Python: transients are created through ocp_new (see
|
||||
// occt_transient.h), never by placement-new into instance storage.
|
||||
nb::class_<Standard_Transient>(m, "Standard_Transient")
|
||||
.def("GetRefCount", &Standard_Transient::GetRefCount)
|
||||
.def("IsInstance",
|
||||
[](const Standard_Transient &self, const char *name) {
|
||||
return self.IsKind(name);
|
||||
})
|
||||
.def("DynamicType", [](const Standard_Transient &self) {
|
||||
return std::string(self.DynamicType()->Name());
|
||||
});
|
||||
|
||||
m.attr("OCC_VERSION_COMPLETE") = OCC_VERSION_COMPLETE;
|
||||
}
|
||||
44
src/modules/mod_TopAbs.cpp
Normal file
44
src/modules/mod_TopAbs.cpp
Normal file
@@ -0,0 +1,44 @@
|
||||
#include "../common/occt_module.h"
|
||||
|
||||
#include <TopAbs.hxx>
|
||||
#include <TopAbs_Orientation.hxx>
|
||||
#include <TopAbs_ShapeEnum.hxx>
|
||||
#include <TopAbs_State.hxx>
|
||||
|
||||
void register_TopAbs(nb::module_ &root) {
|
||||
nb::module_ m = ocp_submodule(root, "TopAbs");
|
||||
|
||||
// is_arithmetic + export_values reproduce the pybind11 enum behaviour the
|
||||
// app relies on: members at module scope (`from OCP.TopAbs import
|
||||
// TopAbs_FACE`) and int comparison.
|
||||
nb::enum_<TopAbs_ShapeEnum>(m, "TopAbs_ShapeEnum", nb::is_arithmetic())
|
||||
.value("TopAbs_COMPOUND", TopAbs_COMPOUND)
|
||||
.value("TopAbs_COMPSOLID", TopAbs_COMPSOLID)
|
||||
.value("TopAbs_SOLID", TopAbs_SOLID)
|
||||
.value("TopAbs_SHELL", TopAbs_SHELL)
|
||||
.value("TopAbs_FACE", TopAbs_FACE)
|
||||
.value("TopAbs_WIRE", TopAbs_WIRE)
|
||||
.value("TopAbs_EDGE", TopAbs_EDGE)
|
||||
.value("TopAbs_VERTEX", TopAbs_VERTEX)
|
||||
.value("TopAbs_SHAPE", TopAbs_SHAPE)
|
||||
.export_values();
|
||||
|
||||
nb::enum_<TopAbs_Orientation>(m, "TopAbs_Orientation", nb::is_arithmetic())
|
||||
.value("TopAbs_FORWARD", TopAbs_FORWARD)
|
||||
.value("TopAbs_REVERSED", TopAbs_REVERSED)
|
||||
.value("TopAbs_INTERNAL", TopAbs_INTERNAL)
|
||||
.value("TopAbs_EXTERNAL", TopAbs_EXTERNAL)
|
||||
.export_values();
|
||||
|
||||
nb::enum_<TopAbs_State>(m, "TopAbs_State", nb::is_arithmetic())
|
||||
.value("TopAbs_IN", TopAbs_IN)
|
||||
.value("TopAbs_OUT", TopAbs_OUT)
|
||||
.value("TopAbs_ON", TopAbs_ON)
|
||||
.value("TopAbs_UNKNOWN", TopAbs_UNKNOWN)
|
||||
.export_values();
|
||||
|
||||
nb::class_<TopAbs> cls(m, "TopAbs");
|
||||
OCP_DEF_S(cls, "Compose", &TopAbs::Compose, "Or1"_a, "Or2"_a);
|
||||
OCP_DEF_S(cls, "Reverse", &TopAbs::Reverse, "Or"_a);
|
||||
OCP_DEF_S(cls, "Complement", &TopAbs::Complement, "Or"_a);
|
||||
}
|
||||
48
src/modules/mod_TopExp.cpp
Normal file
48
src/modules/mod_TopExp.cpp
Normal file
@@ -0,0 +1,48 @@
|
||||
#include "../common/occt_module.h"
|
||||
#include "../common/occt_policies.h"
|
||||
|
||||
#include <TopExp.hxx>
|
||||
#include <TopExp_Explorer.hxx>
|
||||
#include <TopTools_IndexedDataMapOfShapeListOfShape.hxx>
|
||||
#include <TopTools_IndexedMapOfShape.hxx>
|
||||
|
||||
void register_TopExp(nb::module_ &root) {
|
||||
nb::module_ m = ocp_submodule(root, "TopExp");
|
||||
|
||||
nb::class_<TopExp_Explorer>(m, "TopExp_Explorer")
|
||||
.def(nb::init<>())
|
||||
.def(nb::init<const TopoDS_Shape &, TopAbs_ShapeEnum,
|
||||
TopAbs_ShapeEnum>(),
|
||||
"S"_a, "ToFind"_a, "ToAvoid"_a = TopAbs_SHAPE)
|
||||
.def("Init", &TopExp_Explorer::Init, "S"_a, "ToFind"_a,
|
||||
"ToAvoid"_a = TopAbs_SHAPE)
|
||||
.def("More", &TopExp_Explorer::More)
|
||||
.def("Next", &TopExp_Explorer::Next)
|
||||
.def("Current", &TopExp_Explorer::Current, OCP_RETURN_COPY)
|
||||
.def("Value", &TopExp_Explorer::Value, OCP_RETURN_COPY)
|
||||
.def("ReInit", &TopExp_Explorer::ReInit)
|
||||
.def("Depth", &TopExp_Explorer::Depth)
|
||||
.def("Clear", &TopExp_Explorer::Clear);
|
||||
|
||||
nb::class_<TopExp> cls(m, "TopExp");
|
||||
|
||||
// MapShapes is the single most-called static in the backend (54 sites).
|
||||
OCP_DEF_S(
|
||||
cls, "MapShapes",
|
||||
[](const TopoDS_Shape &S, TopAbs_ShapeEnum T,
|
||||
TopTools_IndexedMapOfShape &M) { TopExp::MapShapes(S, T, M); },
|
||||
"S"_a, "T"_a, "M"_a);
|
||||
OCP_DEF_S(
|
||||
cls, "MapShapes",
|
||||
[](const TopoDS_Shape &S, TopTools_IndexedMapOfShape &M) {
|
||||
TopExp::MapShapes(S, M);
|
||||
},
|
||||
"S"_a, "M"_a);
|
||||
OCP_DEF_S(
|
||||
cls, "MapShapesAndAncestors",
|
||||
[](const TopoDS_Shape &S, TopAbs_ShapeEnum TS, TopAbs_ShapeEnum TA,
|
||||
TopTools_IndexedDataMapOfShapeListOfShape &M) {
|
||||
TopExp::MapShapesAndAncestors(S, TS, TA, M);
|
||||
},
|
||||
"S"_a, "TS"_a, "TA"_a, "M"_a);
|
||||
}
|
||||
24
src/modules/mod_TopLoc.cpp
Normal file
24
src/modules/mod_TopLoc.cpp
Normal file
@@ -0,0 +1,24 @@
|
||||
#include "../common/occt_module.h"
|
||||
#include "../common/occt_policies.h"
|
||||
|
||||
#include <TopLoc_Location.hxx>
|
||||
|
||||
void register_TopLoc(nb::module_ &root) {
|
||||
nb::module_ m = ocp_submodule(root, "TopLoc");
|
||||
|
||||
nb::class_<TopLoc_Location>(m, "TopLoc_Location")
|
||||
.def(nb::init<>())
|
||||
.def(nb::init<const gp_Trsf &>(), "T"_a)
|
||||
.def("IsIdentity", &TopLoc_Location::IsIdentity)
|
||||
.def("Identity", &TopLoc_Location::Identity)
|
||||
.def("Transformation", &TopLoc_Location::Transformation,
|
||||
OCP_RETURN_COPY)
|
||||
.def("Inverted", &TopLoc_Location::Inverted, OCP_RETURN_COPY)
|
||||
.def("Multiplied", &TopLoc_Location::Multiplied, "Other"_a,
|
||||
OCP_RETURN_COPY)
|
||||
.def("Divided", &TopLoc_Location::Divided, "Other"_a, OCP_RETURN_COPY)
|
||||
.def("Predivided", &TopLoc_Location::Predivided, "Other"_a,
|
||||
OCP_RETURN_COPY)
|
||||
.def("IsEqual", &TopLoc_Location::IsEqual, "Other"_a)
|
||||
.def("IsDifferent", &TopLoc_Location::IsDifferent, "Other"_a);
|
||||
}
|
||||
100
src/modules/mod_TopTools.cpp
Normal file
100
src/modules/mod_TopTools.cpp
Normal file
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
OCP.TopTools — the shape containers the topology code lives in.
|
||||
|
||||
Every accessor returns a shape by value (OCP_RETURN_COPY): a wrapper handed
|
||||
out by FindKey must not become a dangling view when the map is cleared or
|
||||
goes out of scope. build_entity_map_delta tests membership through
|
||||
IndexedMapOfShape::Contains (IsSame semantics), which is why Contains and
|
||||
FindIndex are bound rather than left to a Python-side scan.
|
||||
*/
|
||||
|
||||
#include "../common/occt_module.h"
|
||||
#include "../common/occt_policies.h"
|
||||
|
||||
#include <nanobind/make_iterator.h>
|
||||
|
||||
#include <TopTools_IndexedDataMapOfShapeListOfShape.hxx>
|
||||
#include <TopTools_IndexedMapOfShape.hxx>
|
||||
#include <TopTools_ListOfShape.hxx>
|
||||
|
||||
void register_TopTools(nb::module_ &root) {
|
||||
nb::module_ m = ocp_submodule(root, "TopTools");
|
||||
|
||||
nb::class_<TopTools_ListOfShape>(m, "TopTools_ListOfShape")
|
||||
.def(nb::init<>())
|
||||
.def("Append",
|
||||
[](TopTools_ListOfShape &self, const TopoDS_Shape &s) {
|
||||
self.Append(s);
|
||||
},
|
||||
"theItem"_a)
|
||||
.def("Prepend",
|
||||
[](TopTools_ListOfShape &self, const TopoDS_Shape &s) {
|
||||
self.Prepend(s);
|
||||
},
|
||||
"theItem"_a)
|
||||
.def("Extent", &TopTools_ListOfShape::Extent)
|
||||
.def("Size", &TopTools_ListOfShape::Size)
|
||||
.def("IsEmpty", &TopTools_ListOfShape::IsEmpty)
|
||||
.def("Clear", [](TopTools_ListOfShape &self) { self.Clear(); })
|
||||
.def("First",
|
||||
nb::overload_cast<>(&TopTools_ListOfShape::First, nb::const_),
|
||||
OCP_RETURN_COPY)
|
||||
.def("Last",
|
||||
nb::overload_cast<>(&TopTools_ListOfShape::Last, nb::const_),
|
||||
OCP_RETURN_COPY)
|
||||
.def("__len__", &TopTools_ListOfShape::Size)
|
||||
.def(
|
||||
"__iter__",
|
||||
[](const TopTools_ListOfShape &self) {
|
||||
return nb::make_iterator<nb::rv_policy::copy>(
|
||||
nb::type<TopTools_ListOfShape>(), "ListOfShapeIterator",
|
||||
self.begin(), self.end());
|
||||
},
|
||||
nb::keep_alive<0, 1>());
|
||||
|
||||
nb::class_<TopTools_IndexedMapOfShape>(m, "TopTools_IndexedMapOfShape")
|
||||
.def(nb::init<>())
|
||||
.def("Add",
|
||||
[](TopTools_IndexedMapOfShape &self, const TopoDS_Shape &s) {
|
||||
return self.Add(s);
|
||||
},
|
||||
"theKey"_a)
|
||||
.def("Contains", &TopTools_IndexedMapOfShape::Contains, "theKey"_a)
|
||||
.def("FindKey", &TopTools_IndexedMapOfShape::FindKey, "theIndex"_a,
|
||||
OCP_RETURN_COPY)
|
||||
.def("FindIndex", &TopTools_IndexedMapOfShape::FindIndex, "theKey"_a)
|
||||
.def("Extent", &TopTools_IndexedMapOfShape::Extent)
|
||||
.def("Size", &TopTools_IndexedMapOfShape::Size)
|
||||
.def("IsEmpty", &TopTools_IndexedMapOfShape::IsEmpty)
|
||||
.def("Clear",
|
||||
[](TopTools_IndexedMapOfShape &self) { self.Clear(Standard_True); })
|
||||
.def("RemoveLast", &TopTools_IndexedMapOfShape::RemoveLast)
|
||||
.def("__len__", &TopTools_IndexedMapOfShape::Size);
|
||||
|
||||
nb::class_<TopTools_IndexedDataMapOfShapeListOfShape>(
|
||||
m, "TopTools_IndexedDataMapOfShapeListOfShape")
|
||||
.def(nb::init<>())
|
||||
.def("Contains", &TopTools_IndexedDataMapOfShapeListOfShape::Contains,
|
||||
"theKey"_a)
|
||||
.def("FindKey", &TopTools_IndexedDataMapOfShapeListOfShape::FindKey,
|
||||
"theIndex"_a, OCP_RETURN_COPY)
|
||||
.def("FindIndex", &TopTools_IndexedDataMapOfShapeListOfShape::FindIndex,
|
||||
"theKey"_a)
|
||||
.def("FindFromKey",
|
||||
nb::overload_cast<const TopoDS_Shape &>(
|
||||
&TopTools_IndexedDataMapOfShapeListOfShape::FindFromKey,
|
||||
nb::const_),
|
||||
"theKey"_a, OCP_RETURN_COPY)
|
||||
.def("FindFromIndex",
|
||||
nb::overload_cast<Standard_Integer>(
|
||||
&TopTools_IndexedDataMapOfShapeListOfShape::FindFromIndex,
|
||||
nb::const_),
|
||||
"theIndex"_a, OCP_RETURN_COPY)
|
||||
.def("Extent", &TopTools_IndexedDataMapOfShapeListOfShape::Extent)
|
||||
.def("Size", &TopTools_IndexedDataMapOfShapeListOfShape::Size)
|
||||
.def("Clear",
|
||||
[](TopTools_IndexedDataMapOfShapeListOfShape &self) {
|
||||
self.Clear(Standard_True);
|
||||
})
|
||||
.def("__len__", &TopTools_IndexedDataMapOfShapeListOfShape::Size);
|
||||
}
|
||||
114
src/modules/mod_TopoDS.cpp
Normal file
114
src/modules/mod_TopoDS.cpp
Normal file
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
OCP.TopoDS — shapes are value types, and that is the point.
|
||||
|
||||
A TopoDS_Shape is a small value (a handle to its TShape, a location, an
|
||||
orientation), so every shape crossing into Python is an owned copy. A
|
||||
wrapper can therefore never alias storage owned by an explorer, a map or a
|
||||
BOP history list — which is the lifetime class that segfaulted a
|
||||
process-global face memo under upstream OCP.
|
||||
|
||||
Fidelity note on __hash__ / __eq__, verified against the stock wheel:
|
||||
upstream binds __hash__ (TShape + Location) and leaves __eq__ at Python's
|
||||
default identity comparison. That pairing looks odd — two re-extracted
|
||||
copies of one face hash equal but compare unequal — and it is exactly what
|
||||
cad/topology/geom_memo.py is built around: it buckets on hash(face) and
|
||||
disambiguates with IsSame, because == cannot be trusted. Binding __eq__ to
|
||||
IsEqual here would silently change that memo's behaviour, so we match
|
||||
upstream rather than improve on it.
|
||||
*/
|
||||
|
||||
#include "../common/occt_module.h"
|
||||
#include "../common/occt_policies.h"
|
||||
|
||||
#include <TopoDS.hxx>
|
||||
#include <TopoDS_CompSolid.hxx>
|
||||
#include <TopoDS_Compound.hxx>
|
||||
#include <TopoDS_Edge.hxx>
|
||||
#include <TopoDS_Face.hxx>
|
||||
#include <TopoDS_Iterator.hxx>
|
||||
#include <TopoDS_Shape.hxx>
|
||||
#include <TopoDS_Shell.hxx>
|
||||
#include <TopoDS_Solid.hxx>
|
||||
#include <TopoDS_Vertex.hxx>
|
||||
#include <TopoDS_Wire.hxx>
|
||||
|
||||
#include <functional>
|
||||
|
||||
namespace {
|
||||
/// Stands in for the TopoDS namespace so its statics can hang off a Python
|
||||
/// class of that name — see the comment at the downcast block below.
|
||||
struct TopoDSStatics {};
|
||||
} // namespace
|
||||
|
||||
void register_TopoDS(nb::module_ &root) {
|
||||
nb::module_ m = ocp_submodule(root, "TopoDS");
|
||||
|
||||
nb::class_<TopoDS_Shape>(m, "TopoDS_Shape")
|
||||
.def(nb::init<>())
|
||||
.def("IsNull", &TopoDS_Shape::IsNull)
|
||||
.def("Nullify", &TopoDS_Shape::Nullify)
|
||||
.def("ShapeType", &TopoDS_Shape::ShapeType)
|
||||
.def("Orientation",
|
||||
nb::overload_cast<>(&TopoDS_Shape::Orientation, nb::const_))
|
||||
.def("Location",
|
||||
nb::overload_cast<>(&TopoDS_Shape::Location, nb::const_),
|
||||
OCP_RETURN_COPY)
|
||||
.def("Closed", nb::overload_cast<>(&TopoDS_Shape::Closed, nb::const_))
|
||||
.def("Reverse", &TopoDS_Shape::Reverse)
|
||||
.def("Reversed", &TopoDS_Shape::Reversed, OCP_RETURN_COPY)
|
||||
.def("Moved", &TopoDS_Shape::Moved, "position"_a,
|
||||
"raiseExc"_a = Standard_False, OCP_RETURN_COPY)
|
||||
.def("Located", &TopoDS_Shape::Located, "loc"_a,
|
||||
"raiseExc"_a = Standard_False, OCP_RETURN_COPY)
|
||||
.def("IsSame", &TopoDS_Shape::IsSame, "other"_a)
|
||||
.def("IsEqual", &TopoDS_Shape::IsEqual, "other"_a)
|
||||
.def("IsPartner", &TopoDS_Shape::IsPartner, "other"_a)
|
||||
.def("__hash__", [](const TopoDS_Shape &s) {
|
||||
return std::hash<TopoDS_Shape>{}(s);
|
||||
});
|
||||
|
||||
#define OCP_SHAPE_SUBCLASS(Type) \
|
||||
nb::class_<Type, TopoDS_Shape>(m, #Type).def(nb::init<>())
|
||||
|
||||
OCP_SHAPE_SUBCLASS(TopoDS_Vertex);
|
||||
OCP_SHAPE_SUBCLASS(TopoDS_Edge);
|
||||
OCP_SHAPE_SUBCLASS(TopoDS_Wire);
|
||||
OCP_SHAPE_SUBCLASS(TopoDS_Face);
|
||||
OCP_SHAPE_SUBCLASS(TopoDS_Shell);
|
||||
OCP_SHAPE_SUBCLASS(TopoDS_Solid);
|
||||
OCP_SHAPE_SUBCLASS(TopoDS_CompSolid);
|
||||
OCP_SHAPE_SUBCLASS(TopoDS_Compound);
|
||||
#undef OCP_SHAPE_SUBCLASS
|
||||
|
||||
nb::class_<TopoDS_Iterator>(m, "TopoDS_Iterator")
|
||||
.def(nb::init<>())
|
||||
.def(nb::init<const TopoDS_Shape &, Standard_Boolean,
|
||||
Standard_Boolean>(),
|
||||
"S"_a, "cumOri"_a = Standard_True, "cumLoc"_a = Standard_True)
|
||||
.def("More", &TopoDS_Iterator::More)
|
||||
.def("Next", &TopoDS_Iterator::Next)
|
||||
.def("Value", &TopoDS_Iterator::Value, OCP_RETURN_COPY);
|
||||
|
||||
// Checked downcasts. These raise Standard_TypeMismatch on a kind
|
||||
// mismatch, which the translator turns into a RuntimeError subclass.
|
||||
//
|
||||
// OCCT 7.9 turned TopoDS from a class into a namespace, but upstream OCP
|
||||
// still presents it as a class carrying the _s statics, and the app calls
|
||||
// TopoDS.Face_s(...). An empty carrier type reproduces that surface.
|
||||
nb::class_<TopoDSStatics> cls(m, "TopoDS");
|
||||
#define OCP_DOWNCAST(Name) \
|
||||
OCP_DEF_S( \
|
||||
cls, #Name, \
|
||||
[](const TopoDS_Shape &s) { return TopoDS::Name(s); }, "S"_a, \
|
||||
OCP_RETURN_COPY)
|
||||
|
||||
OCP_DOWNCAST(Vertex);
|
||||
OCP_DOWNCAST(Edge);
|
||||
OCP_DOWNCAST(Wire);
|
||||
OCP_DOWNCAST(Face);
|
||||
OCP_DOWNCAST(Shell);
|
||||
OCP_DOWNCAST(Solid);
|
||||
OCP_DOWNCAST(CompSolid);
|
||||
OCP_DOWNCAST(Compound);
|
||||
#undef OCP_DOWNCAST
|
||||
}
|
||||
138
src/modules/mod_gp.cpp
Normal file
138
src/modules/mod_gp.cpp
Normal file
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
OCP.gp — the Inc 0 subset (points, vectors, directions, axes, transforms).
|
||||
|
||||
The rest of gp (gp_Ax2/Ax3/Pln/Circ/Lin/Pnt2d/Dir2d/Quaternion, which the
|
||||
app also uses) lands with Inc 1, where the curve and surface classes that
|
||||
consume them arrive.
|
||||
*/
|
||||
|
||||
#include "../common/occt_module.h"
|
||||
#include "../common/occt_policies.h"
|
||||
|
||||
#include <gp_Ax1.hxx>
|
||||
#include <gp_Dir.hxx>
|
||||
#include <gp_Pnt.hxx>
|
||||
#include <gp_Trsf.hxx>
|
||||
#include <gp_Vec.hxx>
|
||||
#include <gp_XYZ.hxx>
|
||||
|
||||
void register_gp(nb::module_ &root) {
|
||||
nb::module_ m = ocp_submodule(root, "gp");
|
||||
|
||||
nb::enum_<gp_TrsfForm>(m, "gp_TrsfForm", nb::is_arithmetic())
|
||||
.value("gp_Identity", gp_Identity)
|
||||
.value("gp_Rotation", gp_Rotation)
|
||||
.value("gp_Translation", gp_Translation)
|
||||
.value("gp_PntMirror", gp_PntMirror)
|
||||
.value("gp_Ax1Mirror", gp_Ax1Mirror)
|
||||
.value("gp_Ax2Mirror", gp_Ax2Mirror)
|
||||
.value("gp_Scale", gp_Scale)
|
||||
.value("gp_CompoundTrsf", gp_CompoundTrsf)
|
||||
.value("gp_Other", gp_Other)
|
||||
.export_values();
|
||||
|
||||
nb::class_<gp_XYZ>(m, "gp_XYZ")
|
||||
.def(nb::init<>())
|
||||
.def(nb::init<Standard_Real, Standard_Real, Standard_Real>(), "X"_a,
|
||||
"Y"_a, "Z"_a)
|
||||
.def("X", &gp_XYZ::X)
|
||||
.def("Y", &gp_XYZ::Y)
|
||||
.def("Z", &gp_XYZ::Z);
|
||||
|
||||
nb::class_<gp_Pnt>(m, "gp_Pnt")
|
||||
.def(nb::init<>())
|
||||
.def(nb::init<Standard_Real, Standard_Real, Standard_Real>(), "Xp"_a,
|
||||
"Yp"_a, "Zp"_a)
|
||||
.def(nb::init<const gp_XYZ &>(), "Coord"_a)
|
||||
.def("X", &gp_Pnt::X)
|
||||
.def("Y", &gp_Pnt::Y)
|
||||
.def("Z", &gp_Pnt::Z)
|
||||
.def("SetX", &gp_Pnt::SetX, "X"_a)
|
||||
.def("SetY", &gp_Pnt::SetY, "Y"_a)
|
||||
.def("SetZ", &gp_Pnt::SetZ, "Z"_a)
|
||||
.def("Coord", nb::overload_cast<>(&gp_Pnt::Coord, nb::const_),
|
||||
OCP_RETURN_COPY)
|
||||
.def("Distance", &gp_Pnt::Distance, "Other"_a)
|
||||
.def("SquareDistance", &gp_Pnt::SquareDistance, "Other"_a)
|
||||
.def("IsEqual", &gp_Pnt::IsEqual, "Other"_a, "LinearTolerance"_a)
|
||||
.def("Transform", &gp_Pnt::Transform, "T"_a)
|
||||
.def("Transformed", &gp_Pnt::Transformed, "T"_a, OCP_RETURN_COPY)
|
||||
.def("Translate",
|
||||
nb::overload_cast<const gp_Vec &>(&gp_Pnt::Translate), "V"_a)
|
||||
.def("Translated",
|
||||
nb::overload_cast<const gp_Vec &>(&gp_Pnt::Translated, nb::const_),
|
||||
"V"_a, OCP_RETURN_COPY);
|
||||
|
||||
nb::class_<gp_Vec>(m, "gp_Vec")
|
||||
.def(nb::init<>())
|
||||
.def(nb::init<Standard_Real, Standard_Real, Standard_Real>(), "Xv"_a,
|
||||
"Yv"_a, "Zv"_a)
|
||||
.def(nb::init<const gp_Dir &>(), "V"_a)
|
||||
.def(nb::init<const gp_Pnt &, const gp_Pnt &>(), "P1"_a, "P2"_a)
|
||||
.def("X", &gp_Vec::X)
|
||||
.def("Y", &gp_Vec::Y)
|
||||
.def("Z", &gp_Vec::Z)
|
||||
.def("Magnitude", &gp_Vec::Magnitude)
|
||||
.def("SquareMagnitude", &gp_Vec::SquareMagnitude)
|
||||
.def("Dot", &gp_Vec::Dot, "Other"_a)
|
||||
.def("Crossed", &gp_Vec::Crossed, "Right"_a, OCP_RETURN_COPY)
|
||||
.def("Normalize", &gp_Vec::Normalize)
|
||||
.def("Normalized", &gp_Vec::Normalized, OCP_RETURN_COPY)
|
||||
.def("Reversed", &gp_Vec::Reversed, OCP_RETURN_COPY)
|
||||
.def("Multiplied", &gp_Vec::Multiplied, "Scalar"_a, OCP_RETURN_COPY)
|
||||
.def("Angle", &gp_Vec::Angle, "Other"_a)
|
||||
.def("Transform", &gp_Vec::Transform, "T"_a)
|
||||
.def("Transformed", &gp_Vec::Transformed, "T"_a, OCP_RETURN_COPY);
|
||||
|
||||
nb::class_<gp_Dir>(m, "gp_Dir")
|
||||
.def(nb::init<>())
|
||||
.def(nb::init<Standard_Real, Standard_Real, Standard_Real>(), "Xv"_a,
|
||||
"Yv"_a, "Zv"_a)
|
||||
.def(nb::init<const gp_Vec &>(), "V"_a)
|
||||
.def("X", &gp_Dir::X)
|
||||
.def("Y", &gp_Dir::Y)
|
||||
.def("Z", &gp_Dir::Z)
|
||||
.def("Dot", &gp_Dir::Dot, "Other"_a)
|
||||
.def("Crossed", &gp_Dir::Crossed, "Right"_a, OCP_RETURN_COPY)
|
||||
.def("Angle", &gp_Dir::Angle, "Other"_a)
|
||||
.def("IsParallel", &gp_Dir::IsParallel, "Other"_a, "AngularTolerance"_a)
|
||||
.def("Reversed", &gp_Dir::Reversed, OCP_RETURN_COPY)
|
||||
.def("Reverse", &gp_Dir::Reverse)
|
||||
.def("Transform", &gp_Dir::Transform, "T"_a)
|
||||
.def("Transformed", &gp_Dir::Transformed, "T"_a, OCP_RETURN_COPY);
|
||||
|
||||
nb::class_<gp_Ax1>(m, "gp_Ax1")
|
||||
.def(nb::init<>())
|
||||
.def(nb::init<const gp_Pnt &, const gp_Dir &>(), "P"_a, "V"_a)
|
||||
.def("Location", &gp_Ax1::Location, OCP_RETURN_COPY)
|
||||
.def("Direction", &gp_Ax1::Direction, OCP_RETURN_COPY)
|
||||
.def("SetLocation", &gp_Ax1::SetLocation, "P"_a)
|
||||
.def("SetDirection", &gp_Ax1::SetDirection, "V"_a)
|
||||
.def("Reversed", &gp_Ax1::Reversed, OCP_RETURN_COPY);
|
||||
|
||||
nb::class_<gp_Trsf>(m, "gp_Trsf")
|
||||
.def(nb::init<>())
|
||||
.def("SetTranslation",
|
||||
nb::overload_cast<const gp_Vec &>(&gp_Trsf::SetTranslation),
|
||||
"V"_a)
|
||||
.def("SetTranslation",
|
||||
nb::overload_cast<const gp_Pnt &, const gp_Pnt &>(
|
||||
&gp_Trsf::SetTranslation),
|
||||
"P1"_a, "P2"_a)
|
||||
.def("SetRotation",
|
||||
nb::overload_cast<const gp_Ax1 &, Standard_Real>(
|
||||
&gp_Trsf::SetRotation),
|
||||
"A1"_a, "Ang"_a)
|
||||
.def("SetScale", &gp_Trsf::SetScale, "P"_a, "S"_a)
|
||||
.def("SetMirror", nb::overload_cast<const gp_Ax1 &>(&gp_Trsf::SetMirror),
|
||||
"A1"_a)
|
||||
.def("Form", &gp_Trsf::Form)
|
||||
.def("ScaleFactor", &gp_Trsf::ScaleFactor)
|
||||
.def("TranslationPart", &gp_Trsf::TranslationPart, OCP_RETURN_COPY)
|
||||
.def("Value", &gp_Trsf::Value, "Row"_a, "Col"_a)
|
||||
.def("Inverted", &gp_Trsf::Inverted, OCP_RETURN_COPY)
|
||||
.def("Multiplied", &gp_Trsf::Multiplied, "T"_a, OCP_RETURN_COPY)
|
||||
.def("Multiply", &gp_Trsf::Multiply, "T"_a)
|
||||
.def("PreMultiply", &gp_Trsf::PreMultiply, "T"_a)
|
||||
.def("Invert", &gp_Trsf::Invert);
|
||||
}
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
51
tests/conftest.py
Normal file
51
tests/conftest.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Shared fixtures.
|
||||
|
||||
Inc 0 binds no modelling API, so shapes for the tests are built the long way:
|
||||
a compound assembled through BRep_Builder, and shapes read back from the
|
||||
committed .brep fixtures (tests/data), which were produced by the *stock*
|
||||
wheel — see tools/gen_fixtures.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
from OCP.BinTools import BinTools
|
||||
from OCP.TopoDS import TopoDS_Shape
|
||||
|
||||
DATA = pathlib.Path(__file__).parent / "data"
|
||||
|
||||
|
||||
def load_brep(path: pathlib.Path) -> TopoDS_Shape:
|
||||
shape = TopoDS_Shape()
|
||||
with path.open("rb") as fh:
|
||||
BinTools.Read_s(shape, io.BytesIO(fh.read()))
|
||||
return shape
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def manifest() -> dict:
|
||||
path = DATA / "manifest.json"
|
||||
if not path.exists():
|
||||
pytest.skip("fixtures not generated — run tools/gen_fixtures.py")
|
||||
return json.loads(path.read_text())
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def fixture_shapes(manifest) -> dict[str, TopoDS_Shape]:
|
||||
return {name: load_brep(DATA / f"{name}.brep") for name in manifest["shapes"]}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def a_face(fixture_shapes):
|
||||
"""First face of the meshed box fixture."""
|
||||
from OCP.TopAbs import TopAbs_FACE
|
||||
from OCP.TopExp import TopExp
|
||||
from OCP.TopTools import TopTools_IndexedMapOfShape
|
||||
|
||||
faces = TopTools_IndexedMapOfShape()
|
||||
TopExp.MapShapes_s(fixture_shapes["box_meshed"], TopAbs_FACE, faces)
|
||||
return faces.FindKey(1)
|
||||
BIN
tests/data/box.brep
Normal file
BIN
tests/data/box.brep
Normal file
Binary file not shown.
BIN
tests/data/box_meshed.brep
Normal file
BIN
tests/data/box_meshed.brep
Normal file
Binary file not shown.
BIN
tests/data/cut_cylinder.brep
Normal file
BIN
tests/data/cut_cylinder.brep
Normal file
Binary file not shown.
BIN
tests/data/empty_compound.brep
Normal file
BIN
tests/data/empty_compound.brep
Normal file
Binary file not shown.
BIN
tests/data/fused.brep
Normal file
BIN
tests/data/fused.brep
Normal file
Binary file not shown.
BIN
tests/data/located_compound.brep
Normal file
BIN
tests/data/located_compound.brep
Normal file
Binary file not shown.
36
tests/data/manifest.json
Normal file
36
tests/data/manifest.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"generated_by": "cadquery-ocp-novtk",
|
||||
"occt_version": "7.9.3.1",
|
||||
"shapes": {
|
||||
"box": {
|
||||
"sha256": "d83ce546f116c93596552ed7628736843c05a6455c13d9e209a0c2830bd6cc35",
|
||||
"size": 4494,
|
||||
"faces": 6
|
||||
},
|
||||
"box_meshed": {
|
||||
"sha256": "b656381c0fd05aae37256e36e15d038a80c5f10df0a4ef4ee570192d23772896",
|
||||
"size": 6931,
|
||||
"faces": 6
|
||||
},
|
||||
"fused": {
|
||||
"sha256": "3535742144c0a789f06b6a1950c5cecfc723e9bfeaf3b3b5cb7c38c78027661e",
|
||||
"size": 10602,
|
||||
"faces": 12
|
||||
},
|
||||
"cut_cylinder": {
|
||||
"sha256": "f91914a18d5faa9ffbb213f46257f00d3a08c09d58f7de96820d7f7431391e8f",
|
||||
"size": 6058,
|
||||
"faces": 7
|
||||
},
|
||||
"located_compound": {
|
||||
"sha256": "4214e4a24a419d0ee7cdc1db113740a645d37ff696a18924bbed602535a8896f",
|
||||
"size": 4618,
|
||||
"faces": 12
|
||||
},
|
||||
"empty_compound": {
|
||||
"sha256": "396c108ce1871b60b5076fddd4baa0266797a2294b4a401a689a9ed7ca1e2de0",
|
||||
"size": 171,
|
||||
"faces": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
86
tests/test_bintools_gate.py
Normal file
86
tests/test_bintools_gate.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""The Inc 0 exit gate: BREP serialisation is byte-identical to the stock wheel.
|
||||
|
||||
Every subprocess pool moves shapes as BinTools bytes, so this is the IPC
|
||||
contract. It is also a storage contract: cad/derive.py content-addresses BREP
|
||||
payloads (payloads/derived/brep/<sha256>.brep) and stores the ref inside the
|
||||
document, so bytes that differ would silently rewrite every derived payload.
|
||||
|
||||
The comparison is our-rewrite vs stock-rewrite. tools/gen_fixtures.py already
|
||||
proved stock is idempotent for each fixture, which is what makes the stored
|
||||
digest a fixed reference rather than one arbitrary encoding of many.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from OCP.BinTools import BinTools
|
||||
from OCP.TopAbs import TopAbs_FACE
|
||||
from OCP.TopExp import TopExp
|
||||
from OCP.TopoDS import TopoDS_Shape
|
||||
from OCP.TopTools import TopTools_IndexedMapOfShape
|
||||
|
||||
from .conftest import DATA
|
||||
|
||||
|
||||
def _rewrite(data: bytes) -> bytes:
|
||||
shape = TopoDS_Shape()
|
||||
BinTools.Read_s(shape, io.BytesIO(data))
|
||||
out = io.BytesIO()
|
||||
BinTools.Write_s(shape, out)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def test_roundtrip_is_byte_identical(manifest):
|
||||
for name, expected in manifest["shapes"].items():
|
||||
original = (DATA / f"{name}.brep").read_bytes()
|
||||
assert hashlib.sha256(original).hexdigest() == expected["sha256"], (
|
||||
f"{name}.brep does not match the manifest — regenerate fixtures"
|
||||
)
|
||||
assert hashlib.sha256(_rewrite(original)).hexdigest() == expected["sha256"], (
|
||||
f"{name}: rewritten bytes differ from the stock wheel's"
|
||||
)
|
||||
|
||||
|
||||
def test_face_map_ordering_survives_the_roundtrip(manifest, fixture_shapes):
|
||||
# Face identity throughout the topology code is the map ordinal, so a
|
||||
# round trip that renumbered faces would silently retarget every anchor.
|
||||
for name, shape in fixture_shapes.items():
|
||||
faces = TopTools_IndexedMapOfShape()
|
||||
TopExp.MapShapes_s(shape, TopAbs_FACE, faces)
|
||||
assert faces.Extent() == manifest["shapes"][name]["faces"], name
|
||||
|
||||
|
||||
def test_empty_compound_survives():
|
||||
shape = TopoDS_Shape()
|
||||
BinTools.Read_s(shape, io.BytesIO((DATA / "empty_compound.brep").read_bytes()))
|
||||
assert not shape.IsNull()
|
||||
|
||||
|
||||
def test_extension_bytes_api_matches_the_drop_in(fixture_shapes):
|
||||
# n3xd_ocp.bintools exists to skip the BytesIO detour on the pool paths; it
|
||||
# is only usable there if it produces the very same bytes.
|
||||
from n3xd_ocp import bintools
|
||||
|
||||
for name, shape in fixture_shapes.items():
|
||||
buf = io.BytesIO()
|
||||
BinTools.Write_s(shape, buf)
|
||||
assert bintools.write_bytes(shape) == buf.getvalue(), name
|
||||
|
||||
restored = bintools.read_bytes(buf.getvalue())
|
||||
assert bintools.write_bytes(restored) == buf.getvalue(), name
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["box", "fused"])
|
||||
def test_write_appends_to_the_stream_position(name):
|
||||
# BinTools.Write_s writes through a file-like object; a caller that already
|
||||
# wrote a header must still find its bytes intact.
|
||||
shape = TopoDS_Shape()
|
||||
BinTools.Read_s(shape, io.BytesIO((DATA / f"{name}.brep").read_bytes()))
|
||||
|
||||
buf = io.BytesIO()
|
||||
buf.write(b"HEADER")
|
||||
BinTools.Write_s(shape, buf)
|
||||
assert buf.getvalue().startswith(b"HEADER")
|
||||
59
tests/test_forksafety.py
Normal file
59
tests/test_forksafety.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Fork safety — what keeps cad_pool's forkserver cheap.
|
||||
|
||||
cad_pool preloads the OCP-importing modules into a forkserver and forks per
|
||||
job (~30 ms) instead of spawning and re-importing OCCT (~1.3 s). That is only
|
||||
sound if importing this binding starts no threads and leaves no state that a
|
||||
fork would invalidate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import multiprocessing
|
||||
import threading
|
||||
|
||||
|
||||
def test_no_threads_after_import():
|
||||
import OCP # noqa: F401
|
||||
|
||||
assert threading.active_count() == 1, threading.enumerate()
|
||||
|
||||
|
||||
def _child(conn):
|
||||
# Runs after the fork: exercise the kernel to prove the inherited image is
|
||||
# usable rather than merely present.
|
||||
import io
|
||||
|
||||
from OCP.BinTools import BinTools
|
||||
from OCP.TopAbs import TopAbs_FACE
|
||||
from OCP.TopExp import TopExp
|
||||
from OCP.TopoDS import TopoDS_Shape
|
||||
from OCP.TopTools import TopTools_IndexedMapOfShape
|
||||
|
||||
from .conftest import DATA
|
||||
|
||||
shape = TopoDS_Shape()
|
||||
BinTools.Read_s(shape, io.BytesIO((DATA / "fused.brep").read_bytes()))
|
||||
faces = TopTools_IndexedMapOfShape()
|
||||
TopExp.MapShapes_s(shape, TopAbs_FACE, faces)
|
||||
|
||||
out = io.BytesIO()
|
||||
BinTools.Write_s(shape, out)
|
||||
conn.send((faces.Extent(), len(out.getvalue())))
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_forkserver_child_can_use_the_kernel():
|
||||
import OCP # noqa: F401
|
||||
|
||||
ctx = multiprocessing.get_context("forkserver")
|
||||
ctx.set_forkserver_preload(["OCP", "OCP.TopoDS", "OCP.BinTools"])
|
||||
|
||||
parent, child = ctx.Pipe()
|
||||
proc = ctx.Process(target=_child, args=(child,))
|
||||
proc.start()
|
||||
faces, size = parent.recv()
|
||||
proc.join(30)
|
||||
|
||||
assert proc.exitcode == 0
|
||||
assert faces == 12
|
||||
assert size > 0
|
||||
147
tests/test_handles.py
Normal file
147
tests/test_handles.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""The handle model (occt_handle.h) — the spike that gates all surface work.
|
||||
|
||||
OCCT transients are reference-counted intrusively, and this binding gives each
|
||||
Python wrapper exactly one of those references, released at deallocation. The
|
||||
properties below are what the rest of the binding is allowed to assume:
|
||||
|
||||
* an object crossing to Python and back keeps its identity while alive;
|
||||
* either side may drop its reference first without taking the object away
|
||||
from the other;
|
||||
* counts stay balanced when a call raises, and across many cycles.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from n3xd_ocp import _debug
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.Poly import Poly_Triangulation
|
||||
from OCP.TopAbs import TopAbs_FACE
|
||||
from OCP.TopExp import TopExp
|
||||
from OCP.TopLoc import TopLoc_Location
|
||||
from OCP.TopoDS import TopoDS
|
||||
from OCP.TopTools import TopTools_IndexedMapOfShape
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def triangulation(fixture_shapes):
|
||||
faces = TopTools_IndexedMapOfShape()
|
||||
TopExp.MapShapes_s(fixture_shapes["box_meshed"], TopAbs_FACE, faces)
|
||||
tri = BRep_Tool.Triangulation_s(TopoDS.Face_s(faces.FindKey(1)),
|
||||
TopLoc_Location())
|
||||
assert tri is not None, "fixture should carry a mesh"
|
||||
return tri
|
||||
|
||||
|
||||
def test_python_constructed_transient_is_heap_owned():
|
||||
# nb::init<> would place the object inside the Python instance and let OCCT
|
||||
# delete storage it does not own; ocp_new heap-allocates instead.
|
||||
tri = Poly_Triangulation(4, 2, False, False)
|
||||
assert tri.NbNodes() == 4
|
||||
assert _debug.refcount(tri) >= 1
|
||||
|
||||
|
||||
def test_kernel_returned_transient_survives_its_shape(fixture_shapes):
|
||||
faces = TopTools_IndexedMapOfShape()
|
||||
TopExp.MapShapes_s(fixture_shapes["box_meshed"], TopAbs_FACE, faces)
|
||||
tri = BRep_Tool.Triangulation_s(TopoDS.Face_s(faces.FindKey(1)),
|
||||
TopLoc_Location())
|
||||
del faces
|
||||
gc.collect()
|
||||
assert tri.NbNodes() > 0 # touches the memory
|
||||
|
||||
|
||||
def test_roundtrip_preserves_wrapper_identity(triangulation):
|
||||
assert _debug.roundtrip(triangulation) is triangulation
|
||||
|
||||
|
||||
def test_cpp_side_reference_keeps_the_object_alive():
|
||||
tri = Poly_Triangulation(8, 4, False, False)
|
||||
slot = _debug.hold(tri)
|
||||
|
||||
del tri
|
||||
gc.collect()
|
||||
|
||||
assert _debug.held_is_alive(slot)
|
||||
assert _debug.held_nb_nodes(slot) == 8 # dereferences it
|
||||
_debug.release_held()
|
||||
|
||||
|
||||
def test_python_reference_survives_the_cpp_side_dropping_its_own():
|
||||
tri = Poly_Triangulation(6, 2, False, False)
|
||||
_debug.hold(tri)
|
||||
_debug.release_held()
|
||||
|
||||
gc.collect()
|
||||
assert tri.NbNodes() == 6
|
||||
|
||||
|
||||
def test_refcount_is_balanced_across_conversions(triangulation):
|
||||
before = _debug.refcount(triangulation)
|
||||
for _ in range(1000):
|
||||
_debug.roundtrip(triangulation)
|
||||
gc.collect()
|
||||
assert _debug.refcount(triangulation) == before
|
||||
|
||||
|
||||
def test_refcount_is_balanced_when_a_call_raises(triangulation):
|
||||
before = _debug.refcount(triangulation)
|
||||
for _ in range(100):
|
||||
with pytest.raises(TypeError):
|
||||
# Argument converts, then the call fails on arity.
|
||||
_debug.roundtrip(triangulation, "unexpected")
|
||||
gc.collect()
|
||||
assert _debug.refcount(triangulation) == before
|
||||
|
||||
|
||||
def _rss_kb() -> int:
|
||||
with open("/proc/self/status") as fh:
|
||||
for line in fh:
|
||||
if line.startswith("VmRSS:"):
|
||||
return int(line.split()[1])
|
||||
raise RuntimeError("VmRSS not found")
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("N3XD_OCP_ASAN") == "1",
|
||||
reason="ASAN quarantines freed memory, so RSS is not a leak signal there",
|
||||
)
|
||||
def test_many_create_destroy_cycles_do_not_leak():
|
||||
# ASAN covers memory *safety*; this covers *growth*. One leaked handle per
|
||||
# cycle would keep every triangulation alive — visible as RSS climbing with
|
||||
# the iteration count rather than settling.
|
||||
def cycle(n: int) -> None:
|
||||
for _ in range(n):
|
||||
tri = Poly_Triangulation(64, 32, False, False)
|
||||
_debug.roundtrip(tri)
|
||||
del tri
|
||||
|
||||
cycle(2_000) # let allocator arenas reach steady state first
|
||||
gc.collect()
|
||||
baseline = _rss_kb()
|
||||
|
||||
cycle(50_000)
|
||||
gc.collect()
|
||||
growth = _rss_kb() - baseline
|
||||
|
||||
# Leaking these would cost megabytes; a few hundred KB of allocator noise
|
||||
# is expected.
|
||||
assert growth < 4_000, f"RSS grew {growth} KB across 50k cycles"
|
||||
|
||||
|
||||
def test_null_handle_maps_to_none(fixture_shapes):
|
||||
# An unmeshed face has no triangulation; OCCT returns a null handle and the
|
||||
# caster must present that as None rather than a wrapper around nullptr.
|
||||
faces = TopTools_IndexedMapOfShape()
|
||||
TopExp.MapShapes_s(fixture_shapes["box"], TopAbs_FACE, faces)
|
||||
tri = BRep_Tool.Triangulation_s(TopoDS.Face_s(faces.FindKey(1)),
|
||||
TopLoc_Location())
|
||||
assert tri is None
|
||||
|
||||
|
||||
def test_none_converts_to_a_null_handle():
|
||||
assert _debug.roundtrip(None) is None
|
||||
assert _debug.refcount(None) == 0
|
||||
62
tests/test_import.py
Normal file
62
tests/test_import.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Import surface: every Inc 0 module present, and nothing started a thread."""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
|
||||
import OCP
|
||||
|
||||
|
||||
def test_version_and_kernel():
|
||||
assert OCP.__version__.startswith(OCP.__occt_version__), (
|
||||
f"wheel version {OCP.__version__} must be prefixed by the OCCT it "
|
||||
f"wraps ({OCP.__occt_version__})"
|
||||
)
|
||||
|
||||
|
||||
def test_every_module_is_importable_and_attribute_reachable():
|
||||
for name in OCP._OCP.__all_modules__:
|
||||
assert f"OCP.{name}" in sys.modules, f"OCP.{name} not in sys.modules"
|
||||
assert getattr(OCP, name) is sys.modules[f"OCP.{name}"]
|
||||
|
||||
|
||||
def test_inc0_modules_present():
|
||||
expected = {
|
||||
"Standard", "StdFail", "gp", "TopAbs", "TopLoc", "TopoDS",
|
||||
"TopTools", "TopExp", "Poly", "BRep", "BinTools",
|
||||
}
|
||||
assert expected <= set(OCP._OCP.__all_modules__)
|
||||
|
||||
|
||||
def test_classes_report_their_real_module():
|
||||
from OCP.TopoDS import TopoDS_Shape
|
||||
|
||||
# Anything importing OCP.TopoDS expects the class to belong there — a flat
|
||||
# extension namespace would break repr and stub generation alike.
|
||||
assert TopoDS_Shape.__module__ == "OCP.TopoDS"
|
||||
|
||||
|
||||
def test_import_starts_no_threads():
|
||||
# cad_pool forks from a forkserver whose preload imports OCP. A thread
|
||||
# started at import would not survive the fork, so this invariant is what
|
||||
# keeps fillet isolation at ~30 ms per job instead of a ~1.3 s respawn.
|
||||
assert threading.active_count() == 1, threading.enumerate()
|
||||
|
||||
|
||||
def test_enums_export_their_values():
|
||||
from OCP.TopAbs import TopAbs_FACE, TopAbs_ShapeEnum
|
||||
|
||||
assert TopAbs_FACE == TopAbs_ShapeEnum.TopAbs_FACE
|
||||
assert int(TopAbs_FACE) == 4 # OCCT's numeric value, relied on by callers
|
||||
|
||||
|
||||
def test_static_methods_carry_the_s_suffix():
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.TopExp import TopExp
|
||||
from OCP.TopoDS import TopoDS
|
||||
|
||||
for holder, name in [
|
||||
(TopExp, "MapShapes_s"),
|
||||
(TopoDS, "Face_s"),
|
||||
(BRep_Tool, "Triangulation_s"),
|
||||
]:
|
||||
assert hasattr(holder, name), f"{holder.__name__}.{name} missing"
|
||||
100
tests/test_shape_identity.py
Normal file
100
tests/test_shape_identity.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Shape hashing and equality — the contract cad/topology/geom_memo.py rests on.
|
||||
|
||||
Verified against the stock wheel before being replicated here: upstream binds
|
||||
__hash__ (TShape + Location) and leaves __eq__ at Python's default identity
|
||||
comparison. That pairing is deliberate on our side too. geom_memo buckets on
|
||||
hash(face) and disambiguates with IsSame precisely because == cannot be
|
||||
trusted; binding __eq__ to IsEqual would collapse entries that memo expects to
|
||||
keep apart, changing behaviour while looking like an improvement.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from OCP.gp import gp_Trsf, gp_Vec
|
||||
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE
|
||||
from OCP.TopExp import TopExp
|
||||
from OCP.TopLoc import TopLoc_Location
|
||||
from OCP.TopTools import TopTools_IndexedMapOfShape
|
||||
|
||||
|
||||
def _faces(shape):
|
||||
faces = TopTools_IndexedMapOfShape()
|
||||
TopExp.MapShapes_s(shape, TopAbs_FACE, faces)
|
||||
return faces
|
||||
|
||||
|
||||
def test_re_extracted_face_hashes_equal_and_is_same(fixture_shapes):
|
||||
shape = fixture_shapes["box"]
|
||||
f1 = _faces(shape).FindKey(1)
|
||||
f2 = _faces(shape).FindKey(1)
|
||||
|
||||
assert f1 is not f2 # separate extractions, separate wrappers
|
||||
assert hash(f1) == hash(f2)
|
||||
assert f1.IsSame(f2)
|
||||
assert f1.IsEqual(f2)
|
||||
|
||||
|
||||
def test_eq_is_identity_not_isequal(fixture_shapes):
|
||||
shape = fixture_shapes["box"]
|
||||
f1 = _faces(shape).FindKey(1)
|
||||
f2 = _faces(shape).FindKey(1)
|
||||
|
||||
# Matches the stock wheel: equal hashes, unequal objects.
|
||||
assert f1 != f2
|
||||
assert f1 == f1
|
||||
|
||||
|
||||
def test_moved_copy_hashes_differently(fixture_shapes):
|
||||
trsf = gp_Trsf()
|
||||
trsf.SetTranslation(gp_Vec(1.0, 0.0, 0.0))
|
||||
|
||||
face = _faces(fixture_shapes["box"]).FindKey(1)
|
||||
moved = face.Moved(TopLoc_Location(trsf))
|
||||
|
||||
assert hash(moved) != hash(face)
|
||||
assert not face.IsSame(moved)
|
||||
|
||||
|
||||
def test_orientation_is_not_part_of_the_hash(fixture_shapes):
|
||||
# Area and centre of mass are orientation-independent, which is why the
|
||||
# memo's key needs no orientation component.
|
||||
face = _faces(fixture_shapes["box"]).FindKey(1)
|
||||
reversed_face = face.Reversed()
|
||||
|
||||
assert hash(reversed_face) == hash(face)
|
||||
assert face.IsSame(reversed_face)
|
||||
assert not face.IsEqual(reversed_face)
|
||||
|
||||
|
||||
def test_subshapes_outlive_their_container(fixture_shapes):
|
||||
"""The lifetime class that segfaulted a process-global face memo."""
|
||||
faces = _faces(fixture_shapes["fused"])
|
||||
picked = [faces.FindKey(i) for i in range(1, faces.Extent() + 1)]
|
||||
del faces
|
||||
|
||||
# Every wrapper owns its own copy, so the map's death is irrelevant.
|
||||
assert all(not f.IsNull() for f in picked)
|
||||
assert len({hash(f) for f in picked}) == len(picked)
|
||||
|
||||
|
||||
def test_explorer_results_outlive_the_explorer(fixture_shapes):
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
|
||||
exp = TopExp_Explorer(fixture_shapes["box"], TopAbs_EDGE)
|
||||
edges = []
|
||||
while exp.More():
|
||||
edges.append(exp.Current())
|
||||
exp.Next()
|
||||
del exp
|
||||
|
||||
assert len(edges) == 24 # a box: 12 edges, each shared by two faces
|
||||
assert all(not e.IsNull() for e in edges)
|
||||
|
||||
|
||||
def test_indexed_map_contains_uses_is_same(fixture_shapes):
|
||||
shape = fixture_shapes["box"]
|
||||
faces = _faces(shape)
|
||||
other = _faces(shape).FindKey(3)
|
||||
|
||||
assert faces.Contains(other)
|
||||
assert faces.FindIndex(other) == 3
|
||||
172
tools/gen_fixtures.py
Normal file
172
tools/gen_fixtures.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Generate the .brep byte-identity fixtures.
|
||||
|
||||
Runs under the STOCK cadquery-ocp-novtk wheel, not this binding — the fixtures
|
||||
are the reference the Inc 0 gate compares against. From the app checkout:
|
||||
|
||||
app/.venv/bin/python ../ocp/tools/gen_fixtures.py
|
||||
|
||||
Two properties are recorded, and the first is checked here rather than assumed:
|
||||
|
||||
* stock is idempotent — reading a shape and writing it back reproduces the
|
||||
same bytes. Without that the gate would be comparing against a moving
|
||||
target, and a mismatch would say nothing.
|
||||
* the digest of those bytes, which is what the gate reproduces. This matters
|
||||
beyond IPC: cad/derive.py content-addresses BREP payloads as
|
||||
payloads/derived/brep/<sha256>.brep and stores the ref in the document, so
|
||||
a binding that serialises differently rewrites every derived payload.
|
||||
|
||||
Face counts are recorded alongside, pinning the map ordering that face and edge
|
||||
identity depend on throughout the topology code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
from OCP.BinTools import BinTools
|
||||
from OCP.BRep import BRep_Builder
|
||||
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
|
||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder
|
||||
from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
|
||||
from OCP.TopAbs import TopAbs_FACE
|
||||
from OCP.TopExp import TopExp
|
||||
from OCP.TopLoc import TopLoc_Location
|
||||
from OCP.TopoDS import TopoDS_Compound, TopoDS_Shape
|
||||
from OCP.TopTools import TopTools_IndexedMapOfShape
|
||||
|
||||
OUT = pathlib.Path(__file__).resolve().parent.parent / "tests" / "data"
|
||||
|
||||
|
||||
def _box(dx=10.0, dy=20.0, dz=30.0) -> TopoDS_Shape:
|
||||
return BRepPrimAPI_MakeBox(dx, dy, dz).Shape()
|
||||
|
||||
|
||||
def _meshed_box() -> TopoDS_Shape:
|
||||
shape = _box()
|
||||
BRepMesh_IncrementalMesh(shape, 0.1, False, 0.5, True)
|
||||
return shape
|
||||
|
||||
|
||||
def _fused() -> TopoDS_Shape:
|
||||
op = BRepAlgoAPI_Fuse()
|
||||
from OCP.TopTools import TopTools_ListOfShape
|
||||
|
||||
args, tools = TopTools_ListOfShape(), TopTools_ListOfShape()
|
||||
args.Append(_box())
|
||||
tools.Append(
|
||||
BRepPrimAPI_MakeBox(gp_Pnt(5, 5, 5), 20.0, 20.0, 20.0).Shape()
|
||||
)
|
||||
op.SetArguments(args)
|
||||
op.SetTools(tools)
|
||||
op.Build()
|
||||
return op.Shape()
|
||||
|
||||
|
||||
def _cut_cylinder() -> TopoDS_Shape:
|
||||
op = BRepAlgoAPI_Cut()
|
||||
from OCP.TopTools import TopTools_ListOfShape
|
||||
|
||||
args, tools = TopTools_ListOfShape(), TopTools_ListOfShape()
|
||||
args.Append(_box())
|
||||
tools.Append(
|
||||
BRepPrimAPI_MakeCylinder(
|
||||
gp_Ax2(gp_Pnt(5, 10, 0), gp_Dir(0, 0, 1)), 3.0, 30.0
|
||||
).Shape()
|
||||
)
|
||||
op.SetArguments(args)
|
||||
op.SetTools(tools)
|
||||
op.Build()
|
||||
return op.Shape()
|
||||
|
||||
|
||||
def _located_compound() -> TopoDS_Shape:
|
||||
"""Exercises the location/TShape sharing part of the format."""
|
||||
builder = BRep_Builder()
|
||||
comp = TopoDS_Compound()
|
||||
builder.MakeCompound(comp)
|
||||
|
||||
base = _box()
|
||||
builder.Add(comp, base)
|
||||
|
||||
trsf = gp_Trsf()
|
||||
trsf.SetTranslation(gp_Vec(50.0, 0.0, 0.0))
|
||||
builder.Add(comp, base.Moved(TopLoc_Location(trsf)))
|
||||
return comp
|
||||
|
||||
|
||||
def _empty_compound() -> TopoDS_Shape:
|
||||
builder = BRep_Builder()
|
||||
comp = TopoDS_Compound()
|
||||
builder.MakeCompound(comp)
|
||||
return comp
|
||||
|
||||
|
||||
SHAPES = {
|
||||
"box": _box,
|
||||
"box_meshed": _meshed_box,
|
||||
"fused": _fused,
|
||||
"cut_cylinder": _cut_cylinder,
|
||||
"located_compound": _located_compound,
|
||||
"empty_compound": _empty_compound,
|
||||
}
|
||||
|
||||
|
||||
def write_bytes(shape: TopoDS_Shape) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
BinTools.Write_s(shape, buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def read_shape(data: bytes) -> TopoDS_Shape:
|
||||
shape = TopoDS_Shape()
|
||||
BinTools.Read_s(shape, io.BytesIO(data))
|
||||
return shape
|
||||
|
||||
|
||||
def face_count(shape: TopoDS_Shape) -> int:
|
||||
faces = TopTools_IndexedMapOfShape()
|
||||
TopExp.MapShapes_s(shape, TopAbs_FACE, faces)
|
||||
return faces.Extent()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import OCP
|
||||
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
manifest: dict = {
|
||||
"generated_by": "cadquery-ocp-novtk",
|
||||
"occt_version": getattr(OCP, "__version__", "unknown"),
|
||||
"shapes": {},
|
||||
}
|
||||
|
||||
for name, build in SHAPES.items():
|
||||
shape = build()
|
||||
data = write_bytes(shape)
|
||||
|
||||
# The gate is "our rewrite == stock rewrite". If stock itself is not
|
||||
# idempotent for a fixture, that fixture cannot serve as a reference.
|
||||
rewritten = write_bytes(read_shape(data))
|
||||
if rewritten != data:
|
||||
print(f"FAIL {name}: stock is not idempotent", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
(OUT / f"{name}.brep").write_bytes(data)
|
||||
manifest["shapes"][name] = {
|
||||
"sha256": hashlib.sha256(data).hexdigest(),
|
||||
"size": len(data),
|
||||
"faces": face_count(shape),
|
||||
}
|
||||
print(f"{name}: {len(data)} bytes, {manifest['shapes'][name]['faces']} faces")
|
||||
|
||||
(OUT / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
print(f"\nwrote {len(SHAPES)} fixtures + manifest to {OUT}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
154
tools/inventory.py
Normal file
154
tools/inventory.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""Symbol inventory: what the app imports from OCP, and what this wheel provides.
|
||||
|
||||
Two jobs, one source of truth:
|
||||
|
||||
--emit parse the app's sources for every `from OCP.<mod> import <name>`
|
||||
plus the attributes reached on those names, and write inventory.json.
|
||||
That file is the coverage work-queue for the remaining increments
|
||||
and, re-run later, the drift detector for newly used symbols.
|
||||
|
||||
--check import the installed OCP and report what the inventory asks for but
|
||||
the wheel does not provide, grouped by module.
|
||||
|
||||
Static analysis only reaches names reached through an imported symbol
|
||||
(`TopExp.MapShapes_s`), not methods called on instances (`shape.IsSame(...)`) —
|
||||
those are covered by running the app's own suite in the parity venv, where a
|
||||
gap is a loud AttributeError. So a green --check is necessary, not sufficient.
|
||||
|
||||
python tools/inventory.py --emit --app ../app/backend
|
||||
python tools/inventory.py --check
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import collections
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
DEFAULT_INVENTORY = HERE.parent / "inventory.json"
|
||||
DEFAULT_APP = HERE.parent.parent / "app" / "backend"
|
||||
|
||||
|
||||
def scan(app_root: pathlib.Path) -> dict:
|
||||
"""Collect {module: {symbol: [attributes reached on it]}} from the app."""
|
||||
modules: dict[str, dict[str, set[str]]] = collections.defaultdict(
|
||||
lambda: collections.defaultdict(set)
|
||||
)
|
||||
files = 0
|
||||
|
||||
for path in sorted(app_root.rglob("*.py")):
|
||||
if "__pycache__" in path.parts:
|
||||
continue
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
except (SyntaxError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# local alias -> (module, original name)
|
||||
local: dict[str, tuple[str, str]] = {}
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module:
|
||||
if node.module == "OCP" or node.module.startswith("OCP."):
|
||||
mod = node.module[4:] or "OCP"
|
||||
for alias in node.names:
|
||||
local[alias.asname or alias.name] = (mod, alias.name)
|
||||
modules[mod][alias.name] # noqa: B018 — create the entry
|
||||
|
||||
if local:
|
||||
files += 1
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if (
|
||||
isinstance(node, ast.Attribute)
|
||||
and isinstance(node.value, ast.Name)
|
||||
and node.value.id in local
|
||||
):
|
||||
mod, name = local[node.value.id]
|
||||
modules[mod][name].add(node.attr)
|
||||
|
||||
return {
|
||||
"app_root": str(app_root),
|
||||
"files_importing_ocp": files,
|
||||
"modules": {
|
||||
mod: {name: sorted(attrs) for name, attrs in sorted(syms.items())}
|
||||
for mod, syms in sorted(modules.items())
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def emit(app_root: pathlib.Path, out: pathlib.Path) -> int:
|
||||
data = scan(app_root)
|
||||
out.write_text(json.dumps(data, indent=2) + "\n")
|
||||
n_mod = len(data["modules"])
|
||||
n_sym = sum(len(v) for v in data["modules"].values())
|
||||
print(f"{n_sym} symbols across {n_mod} modules "
|
||||
f"({data['files_importing_ocp']} files) -> {out}")
|
||||
return 0
|
||||
|
||||
|
||||
def check(inventory: pathlib.Path) -> int:
|
||||
import importlib
|
||||
|
||||
data = json.loads(inventory.read_text())
|
||||
missing: dict[str, list[str]] = {}
|
||||
present = 0
|
||||
|
||||
for mod, symbols in data["modules"].items():
|
||||
try:
|
||||
m = importlib.import_module(f"OCP.{mod}")
|
||||
except ImportError:
|
||||
missing[mod] = [f"<module missing> ({len(symbols)} symbols)"]
|
||||
continue
|
||||
|
||||
for name, attrs in symbols.items():
|
||||
obj = getattr(m, name, None)
|
||||
if obj is None:
|
||||
missing.setdefault(mod, []).append(name)
|
||||
continue
|
||||
present += 1
|
||||
for attr in attrs:
|
||||
if not hasattr(obj, attr):
|
||||
missing.setdefault(mod, []).append(f"{name}.{attr}")
|
||||
|
||||
total = sum(len(v) for v in data["modules"].values())
|
||||
print(f"{present}/{total} imported symbols available")
|
||||
if missing:
|
||||
print(f"\nmissing, by module ({len(missing)} modules):")
|
||||
for mod in sorted(missing):
|
||||
print(f" {mod}: {', '.join(sorted(missing[mod]))}")
|
||||
return 1
|
||||
print("complete")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--emit", action="store_true", help="rewrite the inventory")
|
||||
ap.add_argument("--check", action="store_true",
|
||||
help="compare the installed OCP against the inventory")
|
||||
ap.add_argument("--app", type=pathlib.Path, default=DEFAULT_APP)
|
||||
ap.add_argument("--inventory", type=pathlib.Path, default=DEFAULT_INVENTORY)
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.emit:
|
||||
if not args.app.is_dir():
|
||||
print(f"app sources not found: {args.app}", file=sys.stderr)
|
||||
return 2
|
||||
return emit(args.app, args.inventory)
|
||||
if args.check:
|
||||
if not args.inventory.exists():
|
||||
print(f"no inventory at {args.inventory}; run --emit first",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
return check(args.inventory)
|
||||
|
||||
ap.print_help()
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
53
tools/parity_venv.sh
Executable file
53
tools/parity_venv.sh
Executable file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# Set up a side environment where the app runs against n3xd-ocp instead of the
|
||||
# stock wheel.
|
||||
#
|
||||
# The app's manifests are never touched: a swap is per-environment because both
|
||||
# distributions own the OCP/ import path and a process can hold only one OCCT
|
||||
# build. main therefore keeps resolving cadquery-ocp-novtk until the Phase 10C
|
||||
# cutover, and this venv is how parity is proven in the meantime.
|
||||
#
|
||||
# tools/parity_venv.sh install from the Gitea registry
|
||||
# tools/parity_venv.sh --local install the local wheelhouse build
|
||||
#
|
||||
# Then run whichever slice of the app suite the current increment claims:
|
||||
# cd ../app && UV_PROJECT_ENVIRONMENT=.venv-ocp-parity uv run pytest backend/tests/test_geom_memo.py
|
||||
set -euo pipefail
|
||||
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
OCP_REPO=$(dirname "$HERE")
|
||||
APP="${APP_DIR:-$(dirname "$OCP_REPO")/app}"
|
||||
VENV_NAME="${VENV_NAME:-.venv-ocp-parity}"
|
||||
INDEX="https://git.stroblme.de/api/packages/N3XD/pypi/simple/"
|
||||
|
||||
SOURCE="registry"
|
||||
[ "${1:-}" = "--local" ] && SOURCE="local"
|
||||
|
||||
cd "$APP"
|
||||
export UV_PROJECT_ENVIRONMENT="$VENV_NAME"
|
||||
|
||||
echo "--- syncing the app's dependencies into $VENV_NAME ---"
|
||||
uv sync --all-groups
|
||||
|
||||
# Removal must precede installation: both distributions install a top-level
|
||||
# OCP/, so installing over the stock wheel would leave a half-overwritten mix.
|
||||
echo "--- removing the stock wheel ---"
|
||||
uv pip uninstall cadquery-ocp-novtk || true
|
||||
|
||||
echo "--- installing n3xd-ocp ($SOURCE) ---"
|
||||
if [ "$SOURCE" = "local" ]; then
|
||||
uv pip install "$OCP_REPO"/wheelhouse/*.whl
|
||||
else
|
||||
uv pip install --index-url "$INDEX" --prerelease=allow n3xd-ocp
|
||||
fi
|
||||
|
||||
echo "--- sanity ---"
|
||||
uv run python -c "
|
||||
import OCP
|
||||
print('OCP', OCP.__version__, '/ OCCT', OCP.__occt_version__)
|
||||
print('modules:', len(OCP._OCP.__all_modules__))
|
||||
"
|
||||
|
||||
echo "--- coverage against the app's symbol inventory ---"
|
||||
uv run python "$HERE/inventory.py" --check || \
|
||||
echo "(incomplete coverage is expected until the increments land)"
|
||||
Reference in New Issue
Block a user