10C Inc 2: feature tail

119 of the app's 138 symbols now resolve; only the I/O modules and the Inc 4
tail are left. New: GCE2d, GCPnts, BRepFilletAPI, BRepOffsetAPI,
ShapeAnalysis, ShapeFix, ShapeUpgrade, BRepCheck, BRepTools, BRepLib,
BRepExtrema, BRepClass3d, IntCurvesFace, plus TopTools_HSequenceOfShape.

The fillet and chamfer builders derive BRepBuilderAPI_MakeShape, so their
history comes from the base bound in Inc 1 — which is what lets the app read a
blend's provenance the same way it reads a boolean's. The Inc 2 gate compares
that history exactly, alongside the splitter's, which is what sketch-region
attribution depends on.

Two shapes of deviation, both commented where they are bound:

- Trailing enum arguments are left off four constructors (fillet's
  ChFi3d_FilletShape, MakeThickSolidByJoin's mode/join pair, BRepExtrema's
  Extrema flags, and Inc 1's GeomAPI_ProjectPointOnSurf). An unregistered enum
  cannot serve as a default argument — nanobind converts defaults at binding
  time, so it fails the whole module's import with std::bad_cast. The app never
  overrides any of them, so OCCT's own defaults apply and behaviour is
  unchanged.
- ShapeAnalysis_FreeBounds.ConnectEdgesToWires reassigns a Handle& out
  parameter, which the caster cannot honour because it hands C++ a copy of the
  handle. The lambda splices the result into the sequence the caller passed, so
  the pass-empty-then-read shape the app uses still works. This is the one
  place upstream's holder semantics and ours differ observably.

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 20:09:29 +02:00
parent 340315b6e9
commit 0ce43a94aa
20 changed files with 1261 additions and 1 deletions

224
tests/test_inc2_features.py Normal file
View File

@@ -0,0 +1,224 @@
"""Inc 2 gate: the feature tail reproduces the stock wheel's answers.
Same contract as test_inc1_modeling.py — see its module docstring for why the
app's own suite cannot gate an increment and what the tolerances mean.
The fillet history is the load-bearing check here: a blend's provenance is read
exactly like a boolean's, and it is what attributes the new faces back to the
feature that made them.
"""
from __future__ import annotations
import pytest
from OCP.BRepAlgoAPI import BRepAlgoAPI_Splitter
from OCP.BRepClass3d import BRepClass3d_SolidClassifier
from OCP.BRepExtrema import BRepExtrema_DistShapeShape
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
from OCP.BRepTools import BRepTools, BRepTools_WireExplorer
from OCP.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE
from OCP.TopoDS import TopoDS
from OCP.TopTools import TopTools_ListOfShape
from .test_inc1_modeling import assert_close, brep_sha, history, measure, sub_shapes
@pytest.fixture(scope="module")
def inc2(manifest):
if "inc2" not in manifest:
pytest.skip("manifest predates the Inc 2 reference block")
return manifest["inc2"]
def _box():
return BRepPrimAPI_MakeBox(10.0, 20.0, 30.0).Shape()
def _compare(got: dict, expected: dict, what: str) -> None:
for key in ("n_faces", "n_edges", "n_solids"):
assert got[key] == expected[key], f"{what}.{key}"
for key in ("volume", "area", "bbox"):
assert_close(got[key], expected[key], f"{what}.{key}")
def test_fillet_matches_stock_including_history(inc2):
box = _box()
maker = BRepFilletAPI_MakeFillet(box)
maker.Add(2.0, TopoDS.Edge_s(sub_shapes(box, TopAbs_EDGE)[0]))
maker.Build()
assert maker.IsDone()
filleted = maker.Shape()
expected = inc2["fillet"]
assert brep_sha(filleted) == expected["sha256"]
_compare(measure(filleted), expected, "fillet")
# The blend's provenance, read the same way a boolean's is.
assert history(maker, box, filleted) == expected["history"]
def test_chamfer_matches_stock(inc2):
box = _box()
maker = BRepFilletAPI_MakeChamfer(box)
maker.Add(1.5, TopoDS.Edge_s(sub_shapes(box, TopAbs_EDGE)[0]))
maker.Build()
assert maker.IsDone()
expected = inc2["chamfer"]
assert brep_sha(maker.Shape()) == expected["sha256"]
_compare(measure(maker.Shape()), expected, "chamfer")
def test_splitter_matches_stock_including_history(inc2):
from OCP.gp import gp_Pnt
box = _box()
splitter = BRepAlgoAPI_Splitter()
args, tools = TopTools_ListOfShape(), TopTools_ListOfShape()
args.Append(box)
tools.Append(BRepPrimAPI_MakeBox(gp_Pnt(-5, 5, -5), 20.0, 1.0, 40.0).Shape())
splitter.SetArguments(args)
splitter.SetTools(tools)
splitter.Build()
assert splitter.IsDone()
expected = inc2["splitter"]
_compare(measure(splitter.Shape()), expected, "splitter")
# Region attribution in the sketch builder reads exactly this.
assert history(splitter, box, splitter.Shape()) == expected["history"]
def test_unify_same_domain_matches_stock(inc2, fixture_shapes):
unify = ShapeUpgrade_UnifySameDomain(fixture_shapes["fused"], True, True, True)
unify.Build()
_compare(measure(unify.Shape()), inc2["unified"], "unified")
def test_distance_and_classifier_match_stock(inc2):
from OCP.gp import gp_Pnt
dist = BRepExtrema_DistShapeShape(
_box(), BRepPrimAPI_MakeBox(gp_Pnt(40, 0, 0), 5.0, 5.0, 5.0).Shape()
)
assert dist.IsDone() == inc2["distance"]["is_done"]
assert dist.NbSolution() == inc2["distance"]["n_solutions"]
assert_close(dist.Value(), inc2["distance"]["value"], "distance")
box = _box()
inside = BRepClass3d_SolidClassifier(box, gp_Pnt(5, 10, 15), 1e-7)
outside = BRepClass3d_SolidClassifier(box, gp_Pnt(50, 10, 15), 1e-7)
assert int(inside.State()) == inc2["classifier"]["inside"]
assert int(outside.State()) == inc2["classifier"]["outside"]
def test_brep_tools_queries_match_stock(inc2):
face = TopoDS.Face_s(sub_shapes(_box(), TopAbs_FACE)[0])
assert_close(list(BRepTools.UVBounds_s(face)), inc2["uv_bounds"], "uv_bounds")
outer = BRepTools.OuterWire_s(face)
assert len(sub_shapes(outer, TopAbs_EDGE)) == inc2["outer_wire_edges"]
def test_wire_explorer_walks_in_connection_order():
"""WireExplorer follows connectivity, which is the whole reason to use it
over the map order MapShapes gives."""
face = TopoDS.Face_s(sub_shapes(_box(), TopAbs_FACE)[0])
explorer = BRepTools_WireExplorer(BRepTools.OuterWire_s(face))
walked = []
while explorer.More():
walked.append(explorer.Current())
explorer.Next()
assert len(walked) == 4
# Results outlive the explorer: sub-shapes come back by value.
del explorer
assert all(not e.IsNull() for e in walked)
def test_clean_drops_the_existing_triangulation():
"""OCCT keeps whatever mesh a shape already carries, so re-meshing at a
different deflection is a no-op without this — which is why the
tessellation path calls it before its second pass."""
from OCP.BRep import BRep_Tool
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.TopLoc import TopLoc_Location
shape = _box()
BRepMesh_IncrementalMesh(shape, 0.1, False, 0.5, True)
face = TopoDS.Face_s(sub_shapes(shape, TopAbs_FACE)[0])
assert BRep_Tool.Triangulation_s(face, TopLoc_Location()) is not None
BRepTools.Clean_s(shape)
face = TopoDS.Face_s(sub_shapes(shape, TopAbs_FACE)[0])
assert BRep_Tool.Triangulation_s(face, TopLoc_Location()) is None
def test_free_bounds_reports_an_open_shell():
"""A watertight solid has no free boundary; a shell missing one face
exposes that face's four edges. This is how import health tells the two
apart, and it exercises Sewing on the way.
Note it wants a genuinely sewn shell: a bare face reports nothing, in this
binding and in the stock wheel alike.
"""
from OCP.BRepBuilderAPI import BRepBuilderAPI_Sewing
from OCP.ShapeAnalysis import ShapeAnalysis_FreeBounds
def free_edges(shape) -> int:
bounds = ShapeAnalysis_FreeBounds(shape, 1e-6)
return sum(
len(sub_shapes(wires, TopAbs_EDGE))
for wires in (bounds.GetClosedWires(), bounds.GetOpenWires())
if not wires.IsNull()
)
assert free_edges(_box()) == 0
box = _box()
sewing = BRepBuilderAPI_Sewing(1e-6)
for face in sub_shapes(box, TopAbs_FACE)[:-1]: # leave one face off
sewing.Add(face)
sewing.Perform()
assert free_edges(sewing.SewedShape()) == 4
def test_ray_intersector_finds_the_opposite_wall():
"""The surface-pattern feature measures wall thickness this way."""
from OCP.gp import gp_Dir, gp_Lin, gp_Pnt
from OCP.IntCurvesFace import IntCurvesFace_ShapeIntersector
inter = IntCurvesFace_ShapeIntersector()
inter.Load(_box(), 1e-7)
inter.PerformNearest(
gp_Lin(gp_Pnt(-5.0, 10.0, 15.0), gp_Dir(1.0, 0.0, 0.0)), 0.0, 1.0e6
)
assert inter.IsDone()
assert inter.NbPnt() > 0
assert inter.WParameter(1) == pytest.approx(5.0, abs=1e-6)
def test_curve_length_matches_the_edge():
from OCP.BRepAdaptor import BRepAdaptor_Curve
from OCP.GCPnts import GCPnts_AbscissaPoint, GCPnts_TangentialDeflection
edge = TopoDS.Edge_s(sub_shapes(_box(), TopAbs_EDGE)[0])
adaptor = BRepAdaptor_Curve(edge)
length = GCPnts_AbscissaPoint.Length_s(adaptor)
assert length in (pytest.approx(10.0), pytest.approx(20.0), pytest.approx(30.0))
# The discretiser the viewport's edge polylines come from.
points = GCPnts_TangentialDeflection(adaptor, 0.15, 0.05)
assert points.NbPoints() >= 2
assert not points.Value(1).IsEqual(points.Value(points.NbPoints()), 1e-9)
def test_thick_solid_by_simple_thickens_a_face():
"""The shape rib and the open-profile extrude both lean on this."""
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeThickSolid
face = TopoDS.Face_s(sub_shapes(_box(), TopAbs_FACE)[0])
maker = BRepOffsetAPI_MakeThickSolid()
maker.MakeThickSolidBySimple(face, 2.0)
maker.Build()
assert maker.IsDone()
assert measure(maker.Shape())["n_solids"] == 1