10C Inc 1: core modeling

Binds the modeling core the app builds every feature out of — 93 of its 138
symbols now resolve, up from 34. New modules: GeomAbs, Geom2d, Geom,
TCollection, TColgp, TColStd, GProp, BRepGProp, Bnd, BRepBndLib, Adaptor3d,
BRepAdaptor, GeomLProp, GeomAPI, BRepBuilderAPI, BOPAlgo, BRepAlgoAPI,
BRepPrimAPI, GC, BRepMesh; gp and BRep_Tool completed.

Three structural decisions:

- BRepBuilderAPI_MakeShape carries Build/Shape/Generated/Modified/IsDeleted for
  every maker in the binding, so the booleans, the primitives and (later) the
  fillet builders all answer the app's duck-typed provenance layer through
  ordinary virtual dispatch. History lists come back copied, so they outlive
  the builder.
- The executing two-argument BRepAlgoAPI constructors stay unbound; operands go
  in through SetArguments/SetTools. Section keeps Init1/Init2, which are plain
  setters. BOPAlgo moved up from Inc 2 — SetGlue needs its enum.
- Adaptor3d is registered although the app never imports it: every method it
  calls on BRepAdaptor_Curve/Surface is a virtual declared there, so binding
  them once on the bases leaves mod_BRepAdaptor.cpp with just constructors.

Gate: tests/test_inc1_modeling.py against reference values gen_fixtures.py now
records from the stock wheel — measurements, per-face area/centroid in map
order, mesh counts, and the boolean history map compared exactly, since that is
the substrate the app's topological naming is built on.

tools/sigdiff.py compares our bound surface against stock's, because a wrong
nb::init<> is silent: MakePrism's five-argument form bound OCCT's semi-infinite
gp_Dir overload (gp_Dir converts from gp_Vec), producing a valid solid of the
wrong shape with the flags shifted along. The fixture digest caught it; sigdiff
finds the class of bug directly, and now reports only one deliberate deviation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfriM8XUkn7uYf5Dwe2xo6
This commit is contained in:
2026-08-10 19:52:52 +02:00
parent 091a2ad0cf
commit 2c2c4e4712
32 changed files with 2732 additions and 18 deletions

386
tests/test_inc1_modeling.py Normal file
View File

@@ -0,0 +1,386 @@
"""Inc 1 gate: core modeling reproduces the stock wheel's answers.
The app's own suite cannot run until the last module is bound (its conftest
imports n3xd.main, i.e. the whole OCP surface), so an increment is gated on the
reference block tools/gen_fixtures.py records from the stock wheel.
What is compared, and why in that form:
* counts and boolean history maps — **exactly**. They are topological, so no
tolerance is meaningful, and the history *is* the topological-naming
substrate: the app decides which feature owns which face from it.
* measurements — at a relative tolerance. Same kernel and same conservative
optimiser flags, so they agree to far better than this; the tolerance is
there so a last-ulp difference is not reported as a parity failure.
* constructed shapes — by BREP digest, which is the strongest available
statement and the one cad/derive.py's content addressing depends on.
"""
from __future__ import annotations
import hashlib
import io
import pytest
from OCP.Bnd import Bnd_Box
from OCP.BRep import BRep_Tool
from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
from OCP.BRepBndLib import BRepBndLib
from OCP.BRepBuilderAPI import (
BRepBuilderAPI_MakeEdge,
BRepBuilderAPI_MakeFace,
BRepBuilderAPI_MakePolygon,
)
from OCP.BRepGProp import BRepGProp
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.BRepPrimAPI import (
BRepPrimAPI_MakeBox,
BRepPrimAPI_MakeCylinder,
BRepPrimAPI_MakePrism,
)
from OCP.BinTools import BinTools
from OCP.GC import GC_MakeArcOfCircle
from OCP.Geom import Geom_BSplineCurve
from OCP.GeomAbs import GeomAbs_Cylinder, GeomAbs_Line, GeomAbs_Plane
from OCP.GProp import GProp_GProps
from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt, gp_Vec
from OCP.TColgp import TColgp_Array1OfPnt
from OCP.TColStd import TColStd_Array1OfInteger, TColStd_Array1OfReal
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_SOLID
from OCP.TopExp import TopExp
from OCP.TopLoc import TopLoc_Location
from OCP.TopoDS import TopoDS, TopoDS_Shape
from OCP.TopTools import TopTools_IndexedMapOfShape, TopTools_ListOfShape
REL = 1e-9
@pytest.fixture(scope="module")
def inc1(manifest):
if "inc1" not in manifest:
pytest.skip("manifest predates the Inc 1 reference block")
return manifest["inc1"]
# --------------------------------------------------------------------------
# Helpers, deliberately mirroring tools/gen_fixtures.py
# --------------------------------------------------------------------------
def sub_shapes(shape, kind=None):
smap = TopTools_IndexedMapOfShape()
if kind is None:
TopExp.MapShapes_s(shape, smap)
else:
TopExp.MapShapes_s(shape, kind, smap)
return [smap.FindKey(i) for i in range(1, smap.Extent() + 1)]
def measure(shape) -> dict:
vol, area = GProp_GProps(), GProp_GProps()
BRepGProp.VolumeProperties_s(shape, vol)
BRepGProp.SurfaceProperties_s(shape, area)
box = Bnd_Box()
BRepBndLib.Add_s(shape, box)
return {
"volume": vol.Mass(),
"area": area.Mass(),
"bbox": None if box.IsVoid() else list(box.Get()),
"n_faces": len(sub_shapes(shape, TopAbs_FACE)),
"n_edges": len(sub_shapes(shape, TopAbs_EDGE)),
"n_solids": len(sub_shapes(shape, TopAbs_SOLID)),
}
def history(builder, pre, post) -> list[dict]:
all_post = TopTools_IndexedMapOfShape()
TopExp.MapShapes_s(post, all_post)
return [
{
"pre": i,
"deleted": bool(builder.IsDeleted(face)),
"modified": sorted(
all_post.FindIndex(s) - 1 for s in builder.Modified(face)
),
"generated": sorted(
all_post.FindIndex(s) - 1 for s in builder.Generated(face)
),
}
for i, face in enumerate(sub_shapes(pre, TopAbs_FACE))
]
def brep_sha(shape) -> str:
buf = io.BytesIO()
BinTools.Write_s(shape, buf)
return hashlib.sha256(buf.getvalue()).hexdigest()
def linear(shape) -> float:
props = GProp_GProps()
BRepGProp.LinearProperties_s(shape, props)
return props.Mass()
def assert_close(actual, expected, what: str) -> None:
if expected is None:
assert actual is None, what
return
if isinstance(expected, list):
assert actual == pytest.approx(expected, rel=REL), what
else:
assert actual == pytest.approx(expected, rel=REL), what
# --------------------------------------------------------------------------
# Measurement parity over the committed fixtures
# --------------------------------------------------------------------------
def test_measurements_match_stock(inc1, fixture_shapes):
for name, expected in inc1["measure"].items():
got = measure(fixture_shapes[name])
for key in ("n_faces", "n_edges", "n_solids"):
assert got[key] == expected[key], f"{name}.{key}"
for key in ("volume", "area", "bbox"):
assert_close(got[key], expected[key], f"{name}.{key}")
def test_face_props_match_stock(inc1, fixture_shapes):
"""Per-face area and centroid, in MapShapes(FACE) order.
Also the reference n3xd_ocp.measure.face_surface_props has to reproduce:
the anchor path keys on this ordering.
"""
for name, expected in inc1["face_props"].items():
faces = sub_shapes(fixture_shapes[name], TopAbs_FACE)
assert len(faces) == len(expected), f"{name}: face count"
for i, (face, row) in enumerate(zip(faces, expected, strict=True)):
props = GProp_GProps()
BRepGProp.SurfaceProperties_s(face, props)
c = props.CentreOfMass()
assert_close(
[props.Mass(), c.X(), c.Y(), c.Z()], row, f"{name}.face[{i}]"
)
def test_surface_properties_eps_overload_is_bound():
"""The 3-argument form exists and converges on the same answer for an
analytic face. The app's spline-area accuracy work needs this knob."""
box = BRepPrimAPI_MakeBox(10.0, 20.0, 30.0).Shape()
default, eps = GProp_GProps(), GProp_GProps()
BRepGProp.SurfaceProperties_s(box, default)
BRepGProp.SurfaceProperties_s(box, eps, 1e-6)
assert eps.Mass() == pytest.approx(default.Mass(), rel=1e-9)
# --------------------------------------------------------------------------
# Boolean history — the topological-naming substrate
# --------------------------------------------------------------------------
def _cut_cylinder_op():
op = BRepAlgoAPI_Cut()
base = BRepPrimAPI_MakeBox(10.0, 20.0, 30.0).Shape()
tool = BRepPrimAPI_MakeCylinder(
gp_Ax2(gp_Pnt(5, 10, 0), gp_Dir(0, 0, 1)), 3.0, 30.0
).Shape()
args, tools = TopTools_ListOfShape(), TopTools_ListOfShape()
args.Append(base)
tools.Append(tool)
op.SetArguments(args)
op.SetTools(tools)
op.Build()
return op, base
def test_boolean_history_matches_stock_exactly(inc1):
op, base = _cut_cylinder_op()
assert op.IsDone()
assert history(op, base, op.Shape()) == inc1["cut_cylinder_history"]
def test_history_of_an_untracked_subshape_raises_catchably():
"""The app wraps every history call in `except Exception`, because a builder
need not track an arbitrary sub-shape. Whatever OCCT does there must reach
Python as an exception, never as an abort."""
op, _base = _cut_cylinder_op()
stranger = BRepPrimAPI_MakeBox(1.0, 1.0, 1.0).Shape()
try:
op.Modified(stranger)
op.Generated(stranger)
op.IsDeleted(stranger)
except Exception: # noqa: BLE001 — the point is that it is catchable
pass
def test_executing_boolean_constructors_are_not_bound():
"""The two-argument constructors execute during construction; the app's
call sites also called Build(), so the operation ran twice and the first,
destructive pass could mutate shared inputs. Refusing to bind them is what
makes that unrepresentable."""
a = BRepPrimAPI_MakeBox(10.0, 10.0, 10.0).Shape()
b = BRepPrimAPI_MakeBox(5.0, 5.0, 5.0).Shape()
for cls in (BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse):
with pytest.raises(TypeError):
cls(a, b)
# --------------------------------------------------------------------------
# Constructors
# --------------------------------------------------------------------------
def test_arc_edge_matches_stock(inc1):
arc = GC_MakeArcOfCircle(
gp_Pnt(0, 0, 0), gp_Pnt(5, 5, 0), gp_Pnt(10, 0, 0)
).Value()
edge = BRepBuilderAPI_MakeEdge(arc).Edge()
expected = inc1["constructions"]["arc_edge"]
assert brep_sha(edge) == expected["sha256"]
assert_close(linear(edge), expected["length"], "arc length")
def test_bspline_edge_matches_stock(inc1):
"""A Python-constructed transient handed straight to the kernel.
This is the path that makes nb::init<> unusable for transients: the edge
builder stores a handle to the curve, so the curve must own its own heap
storage rather than live inside the Python instance.
"""
poles = TColgp_Array1OfPnt(1, 4)
for i, (x, y) in enumerate([(0, 0), (3, 6), (7, -4), (10, 2)], start=1):
poles.SetValue(i, gp_Pnt(float(x), float(y), 0.0))
knots = TColStd_Array1OfReal(1, 2)
knots.SetValue(1, 0.0)
knots.SetValue(2, 1.0)
mults = TColStd_Array1OfInteger(1, 2)
mults.SetValue(1, 4)
mults.SetValue(2, 4)
curve = Geom_BSplineCurve(poles, knots, mults, 3)
edge = BRepBuilderAPI_MakeEdge(curve).Edge()
del curve, poles, knots, mults # the edge must not depend on them
expected = inc1["constructions"]["spline_edge"]
assert brep_sha(edge) == expected["sha256"]
assert_close(linear(edge), expected["length"], "spline length")
def test_prism_matches_stock_including_history(inc1):
wire = BRepBuilderAPI_MakePolygon(
gp_Pnt(0, 0, 0), gp_Pnt(10, 0, 0), gp_Pnt(10, 6, 0), gp_Pnt(0, 6, 0), True
).Wire()
face = BRepBuilderAPI_MakeFace(wire, True).Face()
maker = BRepPrimAPI_MakePrism(face, gp_Vec(0.0, 0.0, 4.0))
prism = maker.Shape()
expected = inc1["constructions"]["prism"]
assert brep_sha(prism) == expected["sha256"]
got = measure(prism)
for key in ("n_faces", "n_edges", "n_solids"):
assert got[key] == expected[key], key
for key in ("volume", "area", "bbox"):
assert_close(got[key], expected[key], key)
# An extrude's provenance is read off exactly this map.
assert history(maker, face, prism) == expected["history"]
def test_meshing_matches_stock_counts(inc1):
shape = BRepPrimAPI_MakeBox(10.0, 20.0, 30.0).Shape()
BRepMesh_IncrementalMesh(shape, 0.1, False, 0.5, True)
got = []
for i, face in enumerate(sub_shapes(shape, TopAbs_FACE)):
loc = TopLoc_Location()
tri = BRep_Tool.Triangulation_s(TopoDS.Face_s(face), loc)
if tri is None:
continue # id gaps are preserved, exactly as tessellation.py does
got.append([i, tri.NbNodes(), tri.NbTriangles()])
assert got == inc1["box_meshed_counts"]
# --------------------------------------------------------------------------
# Adaptors — the app's most-used query path
# --------------------------------------------------------------------------
def test_adaptor_reports_surface_kind_and_quadric():
"""`BRepAdaptor_Surface(f).Cylinder().Radius()` is the shape of nearly every
reference-inference call in the app, and it crosses three modules: the
virtuals live on Adaptor3d_Surface, the enum in GeomAbs, the result in gp.
"""
cyl = BRepPrimAPI_MakeCylinder(
gp_Ax2(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1)), 4.0, 12.0
).Shape()
kinds = {}
for face in sub_shapes(cyl, TopAbs_FACE):
adaptor = BRepAdaptor_Surface(TopoDS.Face_s(face))
kinds.setdefault(adaptor.GetType(), []).append(adaptor)
assert len(kinds[GeomAbs_Plane]) == 2
lateral = kinds[GeomAbs_Cylinder][0]
assert lateral.Cylinder().Radius() == pytest.approx(4.0)
assert lateral.Cylinder().Axis().Direction().Z() == pytest.approx(1.0)
def test_curve_adaptor_reports_line_and_bounds():
box = BRepPrimAPI_MakeBox(10.0, 20.0, 30.0).Shape()
edge = TopoDS.Edge_s(sub_shapes(box, TopAbs_EDGE)[0])
adaptor = BRepAdaptor_Curve(edge)
assert adaptor.GetType() == GeomAbs_Line
assert adaptor.LastParameter() > adaptor.FirstParameter()
direction = adaptor.Line().Direction()
assert abs(direction.X()) + abs(direction.Y()) + abs(direction.Z()) == (
pytest.approx(1.0)
)
def test_surface_out_parameter_derivatives():
"""D1 keeps its out-parameters: the app pre-allocates and reads back."""
box = BRepPrimAPI_MakeBox(10.0, 20.0, 30.0).Shape()
face = TopoDS.Face_s(sub_shapes(box, TopAbs_FACE)[0])
adaptor = BRepAdaptor_Surface(face)
point, du, dv = gp_Pnt(), gp_Vec(), gp_Vec()
adaptor.D1(0.5, 0.5, point, du, dv)
assert du.Crossed(dv).Magnitude() > 0.0
def test_brep_tool_surface_round_trips_through_geom():
"""Surface_s hands back a live Geom_Surface, which must survive the trip
back into the kernel — the projection is how feature recognition measures
tangency."""
from OCP.GeomAPI import GeomAPI_ProjectPointOnSurf
box = BRepPrimAPI_MakeBox(10.0, 20.0, 30.0).Shape()
face = TopoDS.Face_s(sub_shapes(box, TopAbs_FACE)[0])
surf = BRep_Tool.Surface_s(face)
proj = GeomAPI_ProjectPointOnSurf(gp_Pnt(-5.0, 10.0, 15.0), surf)
assert proj.IsDone()
assert proj.NbPoints() >= 1
u, v = proj.LowerDistanceParameters()
assert isinstance(u, float) and isinstance(v, float)
def test_bnd_box_get_returns_a_tuple():
box = BRepPrimAPI_MakeBox(10.0, 20.0, 30.0).Shape()
bbox = Bnd_Box()
BRepBndLib.Add_s(box, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
assert xmax - xmin == pytest.approx(10.0, abs=1e-6)
assert ymax - ymin == pytest.approx(20.0, abs=1e-6)
assert zmax - zmin == pytest.approx(30.0, abs=1e-6)
def test_history_survives_its_builder():
"""Sub-shapes come back by value, so a history list outlives the operator
that produced it — the app keeps provenance around long after."""
op, base = _cut_cylinder_op()
pierced = sub_shapes(base, TopAbs_FACE)[4]
modified = list(op.Modified(pierced))
del op
assert modified
assert all(isinstance(s, TopoDS_Shape) and not s.IsNull() for s in modified)