Files
ocp/tests/test_ext_measure.py
stroblme 4bb4805ccb 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
2026-08-10 20:37:01 +02:00

103 lines
3.8 KiB
Python

"""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)