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

@@ -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,