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.
148 lines
4.8 KiB
Python
148 lines
4.8 KiB
Python
"""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
|