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

0
tests/__init__.py Normal file
View File

51
tests/conftest.py Normal file
View File

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

BIN
tests/data/box.brep Normal file

Binary file not shown.

BIN
tests/data/box_meshed.brep Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
tests/data/fused.brep Normal file

Binary file not shown.

Binary file not shown.

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

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

View File

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

59
tests/test_forksafety.py Normal file
View File

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

147
tests/test_handles.py Normal file
View File

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

62
tests/test_import.py Normal file
View File

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

View File

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