n3xd_ocp.measure: every face's area and centroid in one call
BRepGProp.SurfaceProperties is the hottest kernel call the app makes — 94 % of face_candidate_anchors, 0.99 s of 1.05 s for 690 faces — not because the kernel is slow but because it is reached once per face from Python, so a rebuild pays the round-trip hundreds of times per feature over a growing shape. face_surface_props(shape, *, parallel=True, eps=None) runs the whole scan C++-side with the GIL released, optionally over OSD_Parallel, and returns (areas[F], centroids[F,3]) in MapShapes(FACE) order — the face identity the topology layer already keys on, so a caller indexes straight into it. A face OCCT cannot integrate reports zeros, matching what the app's own try/except substitutes. Tested against the same stock-recorded per-face reference the Inc 1 gate uses, and against the one-call-per-face loop it replaces. Parallel and serial are compared with array_equal rather than approx: the parallel path shares one TopoDS_Shape across threads, so an exact match is the evidence that nothing reachable from it gets mutated while measuring. Backend adoption comes after the cutover, so this changes nothing yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfriM8XUkn7uYf5Dwe2xo6
This commit is contained in:
@@ -49,3 +49,11 @@ def a_face(fixture_shapes):
|
||||
faces = TopTools_IndexedMapOfShape()
|
||||
TopExp.MapShapes_s(fixture_shapes["box_meshed"], TopAbs_FACE, faces)
|
||||
return faces.FindKey(1)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def inc1_or_skip(manifest):
|
||||
"""The Inc 1 reference block, for tests outside test_inc1_modeling.py."""
|
||||
if "inc1" not in manifest:
|
||||
pytest.skip("manifest predates the Inc 1 reference block")
|
||||
return manifest["inc1"]
|
||||
|
||||
102
tests/test_ext_measure.py
Normal file
102
tests/test_ext_measure.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""n3xd_ocp.measure — the batched face measurement.
|
||||
|
||||
Checked against the same per-face reference the Inc 1 gate uses, which is
|
||||
recorded from the stock wheel: the batched scan has to agree with calling
|
||||
BRepGProp.SurfaceProperties once per face, in the same MapShapes(FACE) order,
|
||||
or the anchor path would resolve against different numbers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
np = pytest.importorskip("numpy")
|
||||
|
||||
import n3xd_ocp
|
||||
from OCP.BRepGProp import BRepGProp
|
||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||
from OCP.GProp import GProp_GProps
|
||||
from OCP.TopAbs import TopAbs_FACE
|
||||
|
||||
from .test_inc1_modeling import sub_shapes
|
||||
|
||||
|
||||
def _per_face(shape, eps=None):
|
||||
"""The one-call-per-face loop this API exists to replace."""
|
||||
rows = []
|
||||
for face in sub_shapes(shape, TopAbs_FACE):
|
||||
props = GProp_GProps()
|
||||
if eps is None:
|
||||
BRepGProp.SurfaceProperties_s(face, props)
|
||||
else:
|
||||
BRepGProp.SurfaceProperties_s(face, props, eps)
|
||||
centre = props.CentreOfMass()
|
||||
rows.append((props.Mass(), (centre.X(), centre.Y(), centre.Z())))
|
||||
return rows
|
||||
|
||||
|
||||
def test_matches_the_stock_reference(inc1_or_skip, fixture_shapes):
|
||||
for name, expected in inc1_or_skip["face_props"].items():
|
||||
areas, centroids = n3xd_ocp.measure.face_surface_props(fixture_shapes[name])
|
||||
assert areas.shape == (len(expected),), name
|
||||
assert centroids.shape == (len(expected), 3), name
|
||||
for i, row in enumerate(expected):
|
||||
assert areas[i] == pytest.approx(row[0], rel=1e-9), f"{name}[{i}].area"
|
||||
assert centroids[i] == pytest.approx(row[1:], rel=1e-9, abs=1e-12), (
|
||||
f"{name}[{i}].centroid"
|
||||
)
|
||||
|
||||
|
||||
def test_matches_the_per_face_loop(fixture_shapes):
|
||||
shape = fixture_shapes["cut_cylinder"]
|
||||
areas, centroids = n3xd_ocp.measure.face_surface_props(shape)
|
||||
for i, (area, centre) in enumerate(_per_face(shape)):
|
||||
assert areas[i] == pytest.approx(area, rel=1e-12)
|
||||
assert centroids[i] == pytest.approx(centre, rel=1e-12, abs=1e-12)
|
||||
|
||||
|
||||
def test_parallel_and_serial_agree_exactly(fixture_shapes):
|
||||
"""The parallel path shares one TopoDS_Shape across threads. If OCCT
|
||||
mutated anything reachable from it while measuring, this is where it would
|
||||
show — so the comparison is exact, not approximate."""
|
||||
shape = fixture_shapes["fused"]
|
||||
par = n3xd_ocp.measure.face_surface_props(shape, parallel=True)
|
||||
ser = n3xd_ocp.measure.face_surface_props(shape, parallel=False)
|
||||
assert np.array_equal(par[0], ser[0])
|
||||
assert np.array_equal(par[1], ser[1])
|
||||
|
||||
|
||||
def test_eps_overload_is_reachable(fixture_shapes):
|
||||
shape = fixture_shapes["box"]
|
||||
areas, _ = n3xd_ocp.measure.face_surface_props(shape, eps=1e-6)
|
||||
for i, (area, _centre) in enumerate(_per_face(shape, eps=1e-6)):
|
||||
assert areas[i] == pytest.approx(area, rel=1e-12)
|
||||
|
||||
|
||||
def test_empty_shape_returns_empty_arrays(fixture_shapes):
|
||||
areas, centroids = n3xd_ocp.measure.face_surface_props(
|
||||
fixture_shapes["empty_compound"]
|
||||
)
|
||||
assert areas.shape == (0,)
|
||||
assert centroids.shape == (0, 3)
|
||||
|
||||
|
||||
def test_arrays_own_their_memory(fixture_shapes):
|
||||
"""The buffers are allocated C++-side and handed over with a capsule
|
||||
deleter; the arrays must stay valid once every other reference is gone."""
|
||||
import gc
|
||||
|
||||
areas, centroids = n3xd_ocp.measure.face_surface_props(fixture_shapes["box"])
|
||||
expected = float(areas.sum())
|
||||
gc.collect()
|
||||
assert float(areas.sum()) == expected
|
||||
assert centroids.base is not None # the capsule, not the shape
|
||||
|
||||
|
||||
def test_survives_the_shape_being_dropped():
|
||||
"""Nothing in the result points back into the shape."""
|
||||
areas, centroids = n3xd_ocp.measure.face_surface_props(
|
||||
BRepPrimAPI_MakeBox(10.0, 20.0, 30.0).Shape()
|
||||
)
|
||||
assert areas.sum() == pytest.approx(2200.0)
|
||||
assert centroids.shape == (6, 3)
|
||||
Reference in New Issue
Block a user