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

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

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

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

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

147
tests/test_handles.py Normal file
View File

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