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

View File

@@ -77,6 +77,8 @@ target_link_libraries(_OCP PRIVATE
# image nothing.
TKService
TKV3d
# 10E — OCCT 8.0's helix toolkit, reached only through n3xd_ocp.helix.
TKHelix
)
target_compile_definitions(_OCP PRIVATE

View File

@@ -51,6 +51,22 @@ import n3xd_ocp
areas, centroids = n3xd_ocp.measure.face_surface_props(result) # one call for every face
meshes = n3xd_ocp.tess.extract_meshes(result) # triangulated faces, ready to render
data = n3xd_ocp.bintools.write_bytes(result) # BREP bytes, no temp file needed
points, normals, uv_bounds = n3xd_ocp.sample.face_grid(face, 33) # a 33x33 UV grid on one face
```
`n3xd_ocp.helix` reaches OCCT 8.0's TKHelix, which upstream has no binding for. For N segments it wants N pitches, N turn counts and **N+1 diameters** — one per segment boundary, so consecutive values that differ taper across that segment:
```python
from OCP.gp import gp_Ax3, gp_Dir, gp_Pnt
axis = gp_Ax3(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1), gp_Dir(1, 0, 0))
wire, tolerance_reached = n3xd_ocp.helix.pure_helix(axis, 8.0, [1.25], [12.0])
builder = n3xd_ocp.helix.BuilderHelix() # tapered, e.g. an NPT thread
builder.set_parameters(axis, [10.0, 8.0], [2.0], [4.0])
builder.set_approx_parameters(1.0e-4)
builder.perform()
```
## Build

View File

@@ -162,9 +162,28 @@ would put a bare module in `sys.modules`, and a later `import n3xd_ocp`
would then skip its `__init__.py`.
Shipped today: `bintools` (shape ↔ `bytes`, byte-identical to
`OCP.BinTools`), `measure` (batched per-face area and centroid) and `tess`
(triangulated meshes and edge polylines) — see their `.pyi` stubs for full
signatures, or the [usage examples](../README.md#usage) for a quick start.
`OCP.BinTools`), `measure` (batched per-face area and centroid), `tess`
(triangulated meshes and edge polylines), `sample` (a face's UV grid of
points and normals) and `helix` (OCCT 8.0's TKHelix builder) — see their
`.pyi` stubs for full signatures, or the
[usage examples](../README.md#usage) for a quick start.
The array extractors all reproduce the Python loop they replace exactly,
including its quirks, because their callers key on them: `tess` keeps the id
gap left by an untriangulated face, and `sample` samples the way
`numpy.linspace` does — endpoint forced onto the bound rather than
`start + (n-1)*step` — so the surface a caller fits does not move.
`helix` is the one module with no upstream counterpart to match: TKHelix is
new in OCCT 8.0 and `cadquery-ocp` is still on 7.9.3, so a 1:1 surface would
be invented rather than reproduced. It takes Python lists and builds the
`NCollection_Array1` internally, which also avoids binding that template for
one caller. Its parameter shape is not in the OCCT header and cost a probe to
find: for **N segments** `SetParameters` wants N pitches, N turn counts and
**N+1 diameters**, one per segment *boundary*, so consecutive diameters that
differ taper across that segment. Anything else is a
`Standard_ConstructionError` reading only "wrong array dimension", so the
binding checks the shape first and says what it wanted.
## Matching upstream, and how that's checked

View File

@@ -1,6 +1,6 @@
{
"app_root": "/home/stroblme/n3xd/app/backend",
"files_importing_ocp": 115,
"app_root": "../app/backend",
"files_importing_ocp": 116,
"modules": {
"BOPAlgo": {
"BOPAlgo_GlueShift": []
@@ -86,7 +86,8 @@
"BRepPrimAPI_MakeCylinder": [],
"BRepPrimAPI_MakeHalfSpace": [],
"BRepPrimAPI_MakePrism": [],
"BRepPrimAPI_MakeRevol": []
"BRepPrimAPI_MakeRevol": [],
"BRepPrimAPI_MakeSphere": []
},
"BRepTools": {
"BRepTools": [

View File

@@ -9,7 +9,7 @@ name = "n3xd-ocp"
# .devN suffix: the registry never allows republishing a version, and its
# cleanup rule collects only the dev ones. CMake asserts the prefix matches
# the OCCT it found.
version = "8.0.1.1"
version = "8.0.1.2"
description = "nanobind bindings for the OpenCASCADE geometry kernel (drop-in OCP)"
readme = "README.md"
requires-python = ">=3.12"

View File

@@ -16,8 +16,10 @@ import sys
import OCP as _OCP_pkg # noqa: F401 (loads the extension, registering us)
bintools = sys.modules["n3xd_ocp.bintools"]
helix = sys.modules["n3xd_ocp.helix"]
measure = sys.modules["n3xd_ocp.measure"]
sample = sys.modules["n3xd_ocp.sample"]
tess = sys.modules["n3xd_ocp.tess"]
_debug = sys.modules["n3xd_ocp._debug"]
__all__ = ["bintools", "measure", "tess"]
__all__ = ["bintools", "helix", "measure", "sample", "tess"]

View File

@@ -18,12 +18,16 @@ namespace nb = nanobind;
void register_ext_bintools();
void register_ext_debug();
void register_ext_helix();
void register_ext_measure();
void register_ext_sample();
void register_ext_tess();
void register_ext(nb::module_ &) {
register_ext_bintools();
register_ext_debug();
register_ext_helix();
register_ext_measure();
register_ext_sample();
register_ext_tess();
}

125
src/ext/ext_helix.cpp Normal file
View File

@@ -0,0 +1,125 @@
/*
n3xd_ocp.helix — OCCT 8.0's helix builder, over Python lists.
Lives here rather than in OCP.HelixBRep because there is nothing upstream
to match: cadquery-ocp is on 7.9.3, which has no TKHelix at all, so a 1:1
surface would be invented rather than reproduced. When upstream does bind
it, OCP.HelixBRep can be added beside this and the two can disagree
harmlessly.
HelixBRep_BuilderHelix::SetParameters takes NCollection_Array1, which is
not bound; the vectors are converted here instead. The array-valued form
is the one bound because it is the one that carries variable pitch and
variable diameter — tapered threads and variable-pitch springs — which the
scalar overloads cannot express.
*/
#include "../common/occt_module.h"
#include "../common/occt_policies.h"
#include <nanobind/stl/vector.h>
#include <GeomAbs_Shape.hxx>
#include <HelixBRep_BuilderHelix.hxx>
#include <NCollection_Array1.hxx>
#include <TopoDS_Shape.hxx>
#include <gp_Ax3.hxx>
#include <stdexcept>
#include <string>
#include <vector>
namespace {
/// Copy a std::vector into the Array1 the builder wants.
template <typename T>
NCollection_Array1<T> to_array1(const std::vector<T> &values) {
NCollection_Array1<T> array(1, (int) values.size());
for (size_t i = 0; i < values.size(); ++i)
array.SetValue((int) (i + 1), values[i]);
return array;
}
} // namespace
void register_ext_helix() {
nb::module_ m = ocp_named_module("n3xd_ocp.helix");
nb::class_<HelixBRep_BuilderHelix>(m, "BuilderHelix",
"OCCT's composite-helix wire builder.")
.def(nb::init<>())
.def(
"set_parameters",
[](HelixBRep_BuilderHelix &self, const gp_Ax3 &axis,
const std::vector<double> &diameters,
const std::vector<double> &pitches,
const std::vector<double> &nb_turns) {
if (pitches.empty() || pitches.size() != nb_turns.size() ||
diameters.size() != pitches.size() + 1)
throw std::invalid_argument(
"set_parameters: for N segments OCCT wants N pitches, "
"N nb_turns and N+1 diameters (one per segment "
"boundary); got " +
std::to_string(diameters.size()) + ", " +
std::to_string(pitches.size()) + ", " +
std::to_string(nb_turns.size()));
self.SetParameters(axis, to_array1(diameters),
to_array1(pitches), to_array1(nb_turns));
},
"axis"_a, "diameters"_a, "pitches"_a, "nb_turns"_a,
R"doc(Parameters of an N-segment composite helix.
``pitches`` and ``nb_turns`` carry one entry per segment; ``diameters`` carries
N+1, one per segment *boundary*, so consecutive values that differ produce a
taper across that segment. OCCT raises ``Standard_ConstructionError`` for any
other combination, so the shape is checked here first.)doc")
.def(
"set_approx_parameters",
[](HelixBRep_BuilderHelix &self, double tolerance, int max_degree,
GeomAbs_Shape continuity) {
self.SetApproxParameters(tolerance, max_degree, continuity);
},
"tolerance"_a, "max_degree"_a = 8, "continuity"_a = GeomAbs_C1)
.def("perform", &HelixBRep_BuilderHelix::Perform, OCP_NOGIL)
.def("tolerance_reached", &HelixBRep_BuilderHelix::ToleranceReached)
.def("error_status", &HelixBRep_BuilderHelix::ErrorStatus)
.def("warning_status", &HelixBRep_BuilderHelix::WarningStatus)
.def("shape", &HelixBRep_BuilderHelix::Shape, OCP_RETURN_COPY);
m.def(
"pure_helix",
[](const gp_Ax3 &axis, double diameter,
const std::vector<double> &pitches,
const std::vector<double> &nb_turns, double tolerance,
GeomAbs_Shape continuity) {
if (pitches.size() != nb_turns.size() || pitches.empty())
throw std::invalid_argument(
"pure_helix: pitches and nb_turns must be the same "
"non-empty length");
// N+1 boundary diameters, all equal: a constant-diameter helix.
const std::vector<double> diameters(pitches.size() + 1, diameter);
HelixBRep_BuilderHelix builder;
builder.SetParameters(axis, to_array1(diameters),
to_array1(pitches), to_array1(nb_turns));
builder.SetApproxParameters(tolerance, 8, continuity);
{
nb::gil_scoped_release nogil;
builder.Perform();
}
const int status = builder.ErrorStatus();
if (status != 0)
throw std::runtime_error("pure_helix: HelixBRep_BuilderHelix "
"failed with error status " +
std::to_string(status));
return nb::make_tuple(builder.Shape(), builder.ToleranceReached());
},
"axis"_a, "diameter"_a, "pitches"_a, "nb_turns"_a, nb::kw_only(),
"tolerance"_a = 1.0e-4, "continuity"_a = GeomAbs_C1,
R"doc(A constant-diameter helix wire, as ``(wire, tolerance_reached)``.
One ``pitches``/``nb_turns`` pair per segment, so a single-segment call is the
ordinary thread spine. Raises ``RuntimeError`` when the approximation fails
the returned tolerance says how close it actually got, which is what decides
whether the result is usable as a sweep spine.)doc");
}

122
src/ext/ext_sample.cpp Normal file
View File

@@ -0,0 +1,122 @@
/*
n3xd_ocp.sample — a face's UV grid, without the per-sample round trip.
cad/operations/surface_pattern.py walks an (res+1)² UV grid in nested
Python loops: one BRepAdaptor_Surface::D1 plus roughly ten accessor calls
per sample, and the cross product in Python. The kernel can fill the whole
grid in one call with the GIL released.
The output reproduces what the Python loop produces:
* the grid is BRepTools::UVBounds, sampled the way numpy's linspace
samples it — start + i*step with the endpoint forced exactly, so the
sample locations are bit-identical and the fitted surface does not
move. The bounds come back with the arrays for the same reason: the
caller derives its normalised u/v from these numbers rather than
recomputing them.
* normals are du × dv normalised, negated for a reversed face.
* a sample whose cross product is degenerate (a pole or apex) gets the
zero vector, not a unit vector in an arbitrary direction. Callers
test it — `_inward_thickness` skips a sample on `nrm.any()`.
*/
#include "../common/occt_module.h"
#include <nanobind/ndarray.h>
#include <BRepAdaptor_Surface.hxx>
#include <BRepTools.hxx>
#include <TopAbs_Orientation.hxx>
#include <TopoDS_Face.hxx>
#include <gp_Pnt.hxx>
#include <gp_Vec.hxx>
#include <cmath>
#include <stdexcept>
namespace {
template <typename T> nb::capsule owner_of(T *data) {
return nb::capsule(data, [](void *p) noexcept { delete[] (T *) p; });
}
/// numpy's linspace endpoint handling: the last sample is the bound itself,
/// not start + (n-1)*step, which can miss it by an ulp.
inline double linspace_at(double lo, double hi, int i, int n) {
if (n <= 1)
return lo;
return i == n - 1 ? hi : lo + (double) i * (hi - lo) / (double) (n - 1);
}
constexpr double MIN_NORMAL = 1e-12;
} // namespace
void register_ext_sample() {
nb::module_ m = ocp_named_module("n3xd_ocp.sample");
m.def(
"face_grid",
[](const TopoDS_Face &face, int n) {
if (n < 1)
throw std::invalid_argument("face_grid: n must be >= 1");
double umin = 0.0, umax = 0.0, vmin = 0.0, vmax = 0.0;
BRepTools::UVBounds(face, umin, umax, vmin, vmax);
const double sign =
face.Orientation() == TopAbs_REVERSED ? -1.0 : 1.0;
const size_t count = (size_t) n * (size_t) n;
double *points = new double[3 * count];
double *normals = new double[3 * count];
{
nb::gil_scoped_release nogil;
BRepAdaptor_Surface adaptor(face);
gp_Pnt point;
gp_Vec du, dv;
for (int i = 0; i < n; ++i) {
const double u = linspace_at(umin, umax, i, n);
for (int j = 0; j < n; ++j) {
const double v = linspace_at(vmin, vmax, j, n);
adaptor.D1(u, v, point, du, dv);
const size_t at = 3 * ((size_t) i * (size_t) n + j);
points[at + 0] = point.X();
points[at + 1] = point.Y();
points[at + 2] = point.Z();
const double nx = du.Y() * dv.Z() - du.Z() * dv.Y();
const double ny = du.Z() * dv.X() - du.X() * dv.Z();
const double nz = du.X() * dv.Y() - du.Y() * dv.X();
const double mag = std::sqrt(nx * nx + ny * ny + nz * nz);
if (mag > MIN_NORMAL) {
normals[at + 0] = sign * nx / mag;
normals[at + 1] = sign * ny / mag;
normals[at + 2] = sign * nz / mag;
} else {
normals[at + 0] = 0.0;
normals[at + 1] = 0.0;
normals[at + 2] = 0.0;
}
}
}
}
size_t shape[3] = {(size_t) n, (size_t) n, 3};
return nb::make_tuple(
nb::ndarray<nb::numpy, double, nb::ndim<3>>(points, 3, shape,
owner_of(points)),
nb::ndarray<nb::numpy, double, nb::ndim<3>>(normals, 3, shape,
owner_of(normals)),
nb::make_tuple(umin, umax, vmin, vmax));
},
"face"_a, "n"_a,
R"doc(Sample *face* on an n x n grid over its UV bounds.
Returns ``(points[n, n, 3], normals[n, n, 3], (umin, umax, vmin, vmax))`` as
float64 arrays, u along axis 0. Normals are outward (negated for a reversed
face) and unit length, or the zero vector where the surface derivatives are
degenerate. Sample locations match ``numpy.linspace`` over the returned
bounds exactly.)doc");
}

View File

@@ -23,6 +23,7 @@
#include <BRepPrimAPI_MakeHalfSpace.hxx>
#include <BRepPrimAPI_MakePrism.hxx>
#include <BRepPrimAPI_MakeRevol.hxx>
#include <BRepPrimAPI_MakeSphere.hxx>
#include <TopoDS_Face.hxx>
#include <TopoDS_Shell.hxx>
#include <gp_Ax1.hxx>
@@ -69,6 +70,15 @@ void register_BRepPrimAPI(nb::module_ &root) {
const Standard_Real>(),
"Axes"_a, "R1"_a, "R2"_a, "H"_a);
// Not reached by the app, so inventory.py never turned it up. assay's
// gen_flow_fixtures.py builds spheres, and has been unrunnable against the
// app's venv since the 10C cutover without this.
nb::class_<BRepPrimAPI_MakeSphere, BRepBuilderAPI_MakeShape>(
m, "BRepPrimAPI_MakeSphere")
.def(nb::init<const Standard_Real>(), "R"_a)
.def(nb::init<const gp_Pnt &, const Standard_Real>(), "Center"_a, "R"_a)
.def(nb::init<const gp_Ax2 &, const Standard_Real>(), "Axis"_a, "R"_a);
nb::class_<BRepPrimAPI_MakeHalfSpace, BRepBuilderAPI_MakeShape>(
m, "BRepPrimAPI_MakeHalfSpace")
.def(nb::init<const TopoDS_Face &, const gp_Pnt &>(), "Face"_a,

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

View File

@@ -32,6 +32,15 @@ HERE = pathlib.Path(__file__).resolve().parent
DEFAULT_INVENTORY = HERE.parent / "inventory.json"
DEFAULT_APP = HERE.parent.parent / "app" / "backend"
#: Symbols no app source imports, so the scan below cannot see them, but which
#: another consumer of this wheel needs. Hand-maintained: add the symbol and
#: name who wants it, so a later reader can tell a real dependency from a
#: leftover. Merged into the scan, so --emit keeps them.
EXTRA_SYMBOLS: dict[str, dict[str, list[str]]] = {
# assay/scripts/gen_flow_fixtures.py builds spheres for its CFD fixtures.
"BRepPrimAPI": {"BRepPrimAPI_MakeSphere": []},
}
def scan(app_root: pathlib.Path) -> dict:
"""Collect {module: {symbol: [attributes reached on it]}} from the app."""
@@ -70,6 +79,10 @@ def scan(app_root: pathlib.Path) -> dict:
mod, name = local[node.value.id]
modules[mod][name].add(node.attr)
for mod, symbols in EXTRA_SYMBOLS.items():
for name, attrs in symbols.items():
modules[mod][name].update(attrs)
return {
"app_root": str(app_root),
"files_importing_ocp": files,