Add bulk UV sampler, TKHelix and MakeSphere (8.0.1.2)

- n3xd_ocp.sample.face_grid: a face's UV grid of points and outward
  normals in one GIL-free call, sampling exactly where np.linspace does
  so a caller's fitted surface does not move.
- n3xd_ocp.helix: OCCT 8.0's TKHelix, which upstream does not bind at
  all. Takes Python lists rather than NCollection_Array1. Two things the
  header does not say, both found by probing: SetParameters wants N+1
  diameters for N segments (one per boundary, so a taper interpolates),
  and the builder is right-hand only -- a negative pitch is error status
  12, not a mirrored helix.
- Bind BRepPrimAPI_MakeSphere and give inventory.py an EXTRA_SYMBOLS
  addendum for symbols no app source imports. assay's gen_flow_fixtures
  has been unrunnable since the 10C cutover for want of it; the gap was
  wider than --check, since sigdiff is inventory-driven too.

Gates: 105 tests, 139/139 symbols, sigdiff clean, ASAN clean, wheel
self-contained with no libGL/libX11 DT_NEEDED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HbTQ2HYWQwdtGmGJwypt6Z
This commit is contained in:
2026-08-11 16:26:20 +02:00
parent 8175b8aff3
commit 9f86845c25
13 changed files with 573 additions and 8 deletions

118
tests/test_ext_helix.py Normal file
View File

@@ -0,0 +1,118 @@
"""n3xd_ocp.helix — OCCT 8.0's TKHelix builder.
The toolkit is new in 8.0 and has no upstream binding to compare against, so
these are property checks rather than a parity gate: the wire has to be a real
helix of the requested pitch and turn count, and the failure modes have to
surface as Python exceptions rather than a silently empty shape.
"""
from __future__ import annotations
import math
import pytest
import n3xd_ocp
from OCP.BRepGProp import BRepGProp
from OCP.GeomAbs import GeomAbs_C1, GeomAbs_C2
from OCP.gp import gp_Ax3, gp_Dir, gp_Pnt
from OCP.GProp import GProp_GProps
from OCP.TopAbs import TopAbs_EDGE
from OCP.TopExp import TopExp
from OCP.TopTools import TopTools_IndexedMapOfShape
AXIS = gp_Ax3(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1), gp_Dir(1, 0, 0))
def _length(shape) -> float:
props = GProp_GProps()
BRepGProp.LinearProperties_s(shape, props)
return props.Mass()
def _edge_count(shape) -> int:
edges = TopTools_IndexedMapOfShape()
TopExp.MapShapes_s(shape, TopAbs_EDGE, edges)
return edges.Extent()
def test_single_segment_has_the_right_arc_length():
"""One turn of a helix is sqrt(circumference^2 + pitch^2) long."""
diameter, pitch, turns = 8.0, 1.25, 1.0
wire, reached = n3xd_ocp.helix.pure_helix(AXIS, diameter, [pitch], [turns])
expected = turns * math.hypot(math.pi * diameter, pitch)
assert _length(wire) == pytest.approx(expected, rel=1e-4)
assert reached > 0.0
assert _edge_count(wire) >= 1
@pytest.mark.parametrize("turns", [0.5, 3.0, 24.0])
def test_arc_length_scales_with_the_turn_count(turns):
diameter, pitch = 8.0, 1.25
wire, _reached = n3xd_ocp.helix.pure_helix(AXIS, diameter, [pitch], [turns])
expected = turns * math.hypot(math.pi * diameter, pitch)
assert _length(wire) == pytest.approx(expected, rel=1e-4)
def test_variable_pitch_segments_compose():
"""The array form is the reason this is bound at all — a spring whose
pitch changes partway is one wire, not two."""
diameter = 10.0
wire, _reached = n3xd_ocp.helix.pure_helix(
AXIS, diameter, [2.0, 5.0], [3.0, 2.0]
)
expected = 3.0 * math.hypot(math.pi * diameter, 2.0) + 2.0 * math.hypot(
math.pi * diameter, 5.0
)
assert _length(wire) == pytest.approx(expected, rel=1e-3)
def test_tapered_diameters_via_the_builder():
"""Two segments, so three boundary diameters: 10 -> 8 tapering, then 8 -> 8
straight. A tapered thread (NPT) is the reason the array form is bound."""
builder = n3xd_ocp.helix.BuilderHelix()
builder.set_parameters(AXIS, [10.0, 8.0, 8.0], [2.0, 2.0], [2.0, 2.0])
builder.set_approx_parameters(1.0e-4, 8, GeomAbs_C1)
builder.perform()
assert builder.error_status() == 0
# The straight half is exact; the tapered half is longer than a cylinder of
# its smaller diameter and shorter than one of its larger.
straight = 2.0 * math.hypot(math.pi * 8.0, 2.0)
largest = 2.0 * math.hypot(math.pi * 10.0, 2.0)
total = _length(builder.shape())
assert straight * 2 < total < straight + largest
def test_the_n_plus_one_diameter_rule_is_enforced():
"""OCCT wants N+1 diameters for N segments and raises
Standard_ConstructionError otherwise; the binding checks it first so the
message names the shape it wanted."""
builder = n3xd_ocp.helix.BuilderHelix()
with pytest.raises(ValueError, match="N\\+1 diameters"):
builder.set_parameters(AXIS, [8.0], [1.25], [1.0])
builder.set_parameters(AXIS, [8.0, 8.0], [1.25], [1.0]) # accepted
def test_continuity_is_selectable():
wire_c1, _ = n3xd_ocp.helix.pure_helix(
AXIS, 8.0, [1.25], [2.0], continuity=GeomAbs_C1
)
wire_c2, _ = n3xd_ocp.helix.pure_helix(
AXIS, 8.0, [1.25], [2.0], continuity=GeomAbs_C2
)
assert _length(wire_c1) == pytest.approx(_length(wire_c2), rel=1e-3)
def test_mismatched_segment_arrays_are_rejected():
with pytest.raises(ValueError):
n3xd_ocp.helix.pure_helix(AXIS, 8.0, [1.0, 2.0], [1.0])
with pytest.raises(ValueError):
n3xd_ocp.helix.pure_helix(AXIS, 8.0, [], [])
def test_builder_rejects_ragged_parameters():
builder = n3xd_ocp.helix.BuilderHelix()
with pytest.raises(ValueError):
builder.set_parameters(AXIS, [8.0, 8.0], [1.0], [1.0, 2.0])
with pytest.raises(ValueError):
builder.set_parameters(AXIS, [8.0, 8.0], [], [])

133
tests/test_ext_sample.py Normal file
View File

@@ -0,0 +1,133 @@
"""n3xd_ocp.sample — the bulk UV-grid sampler.
The reference is the nested Python loop in cad/operations/surface_pattern.py's
``_sample_grid``, reproduced below. It has to match exactly rather than
closely: the samples feed a B-spline fit, so a last-ulp difference in a sample
location moves the patterned surface and would make the corpus disagree with
itself for no reason.
"""
from __future__ import annotations
import pytest
np = pytest.importorskip("numpy")
import n3xd_ocp
from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder
from OCP.BRepTools import BRepTools
from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt, gp_Vec
from OCP.TopAbs import TopAbs_FACE, TopAbs_REVERSED
from OCP.TopoDS import TopoDS
from .test_inc1_modeling import sub_shapes
_MIN_NORMAL = 1e-12
def _faces(shape):
return [TopoDS.Face_s(f) for f in sub_shapes(shape, TopAbs_FACE)]
def _python_grid(face, n):
"""surface_pattern.py::_sample_grid, reduced to points and normals."""
adaptor = BRepAdaptor_Surface(face)
umin, umax, vmin, vmax = BRepTools.UVBounds_s(face)
sign = -1.0 if face.Orientation() == TopAbs_REVERSED else 1.0
us = np.linspace(umin, umax, n)
vs = np.linspace(vmin, vmax, n)
point = np.zeros((n, n, 3))
normal = np.zeros((n, n, 3))
pnt, du, dv = gp_Pnt(), gp_Vec(), gp_Vec()
for i in range(n):
up = float(us[i])
for j in range(n):
adaptor.D1(up, float(vs[j]), pnt, du, dv)
point[i, j] = (pnt.X(), pnt.Y(), pnt.Z())
nx = du.Y() * dv.Z() - du.Z() * dv.Y()
ny = du.Z() * dv.X() - du.X() * dv.Z()
nz = du.X() * dv.Y() - du.Y() * dv.X()
mag = (nx * nx + ny * ny + nz * nz) ** 0.5
if mag > _MIN_NORMAL:
normal[i, j] = (sign * nx / mag, sign * ny / mag, sign * nz / mag)
return point, normal, (umin, umax, vmin, vmax)
@pytest.fixture(scope="module")
def shapes():
return {
"box": BRepPrimAPI_MakeBox(10.0, 20.0, 30.0).Shape(),
"cylinder": BRepPrimAPI_MakeCylinder(
gp_Ax2(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1)), 5.0, 12.0
).Shape(),
}
@pytest.mark.parametrize("name", ["box", "cylinder"])
@pytest.mark.parametrize("n", [5, 33, 65])
def test_matches_the_python_loop_exactly(shapes, name, n):
for face in _faces(shapes[name]):
points, normals, bounds = n3xd_ocp.sample.face_grid(face, n)
ref_points, ref_normals, ref_bounds = _python_grid(face, n)
assert bounds == ref_bounds
assert np.array_equal(points, ref_points)
assert np.array_equal(normals, ref_normals)
def test_reversed_face_flips_the_normal(shapes):
"""A box has both orientations; the sign has to follow each face."""
seen = set()
for face in _faces(shapes["box"]):
_points, normals, _bounds = n3xd_ocp.sample.face_grid(face, 5)
_rp, ref_normals, _rb = _python_grid(face, 5)
seen.add(face.Orientation() == TopAbs_REVERSED)
assert np.array_equal(normals, ref_normals)
assert seen == {True, False}, "fixture must cover both orientations"
def test_normals_are_unit_length(shapes):
for face in _faces(shapes["cylinder"]):
_points, normals, _bounds = n3xd_ocp.sample.face_grid(face, 17)
lengths = np.linalg.norm(normals.reshape(-1, 3), axis=1)
assert np.allclose(lengths, 1.0, atol=1e-12)
def test_endpoints_hit_the_bounds_exactly(shapes):
"""linspace forces the last sample onto the bound; start + (n-1)*step can
miss it by an ulp, which is enough to move a fitted surface."""
face = _faces(shapes["cylinder"])[0]
n = 33
points, _normals, (umin, umax, vmin, vmax) = n3xd_ocp.sample.face_grid(face, n)
adaptor = BRepAdaptor_Surface(face)
for i, u in ((0, umin), (n - 1, umax)):
for j, v in ((0, vmin), (n - 1, vmax)):
expected = adaptor.Value(u, v)
assert points[i, j, 0] == expected.X()
assert points[i, j, 1] == expected.Y()
assert points[i, j, 2] == expected.Z()
def test_single_sample_is_the_lower_bound(shapes):
face = _faces(shapes["box"])[0]
points, _normals, (umin, _umax, vmin, _vmax) = n3xd_ocp.sample.face_grid(face, 1)
assert points.shape == (1, 1, 3)
expected = BRepAdaptor_Surface(face).Value(umin, vmin)
assert points[0, 0, 0] == expected.X()
def test_rejects_a_non_positive_grid(shapes):
with pytest.raises(ValueError):
n3xd_ocp.sample.face_grid(_faces(shapes["box"])[0], 0)
def test_arrays_own_their_memory(shapes):
import gc
points, normals, _bounds = n3xd_ocp.sample.face_grid(
_faces(BRepPrimAPI_MakeBox(1.0, 2.0, 3.0).Shape())[0], 9
)
expected = float(points.sum())
gc.collect()
assert float(points.sum()) == expected
assert normals.base is not None # the capsule, not the face