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:
@@ -77,6 +77,8 @@ target_link_libraries(_OCP PRIVATE
|
|||||||
# image nothing.
|
# image nothing.
|
||||||
TKService
|
TKService
|
||||||
TKV3d
|
TKV3d
|
||||||
|
# 10E — OCCT 8.0's helix toolkit, reached only through n3xd_ocp.helix.
|
||||||
|
TKHelix
|
||||||
)
|
)
|
||||||
|
|
||||||
target_compile_definitions(_OCP PRIVATE
|
target_compile_definitions(_OCP PRIVATE
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -51,6 +51,22 @@ import n3xd_ocp
|
|||||||
areas, centroids = n3xd_ocp.measure.face_surface_props(result) # one call for every face
|
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
|
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
|
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
|
## Build
|
||||||
|
|||||||
@@ -162,9 +162,28 @@ would put a bare module in `sys.modules`, and a later `import n3xd_ocp`
|
|||||||
would then skip its `__init__.py`.
|
would then skip its `__init__.py`.
|
||||||
|
|
||||||
Shipped today: `bintools` (shape ↔ `bytes`, byte-identical to
|
Shipped today: `bintools` (shape ↔ `bytes`, byte-identical to
|
||||||
`OCP.BinTools`), `measure` (batched per-face area and centroid) and `tess`
|
`OCP.BinTools`), `measure` (batched per-face area and centroid), `tess`
|
||||||
(triangulated meshes and edge polylines) — see their `.pyi` stubs for full
|
(triangulated meshes and edge polylines), `sample` (a face's UV grid of
|
||||||
signatures, or the [usage examples](../README.md#usage) for a quick start.
|
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
|
## Matching upstream, and how that's checked
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"app_root": "/home/stroblme/n3xd/app/backend",
|
"app_root": "../app/backend",
|
||||||
"files_importing_ocp": 115,
|
"files_importing_ocp": 116,
|
||||||
"modules": {
|
"modules": {
|
||||||
"BOPAlgo": {
|
"BOPAlgo": {
|
||||||
"BOPAlgo_GlueShift": []
|
"BOPAlgo_GlueShift": []
|
||||||
@@ -86,7 +86,8 @@
|
|||||||
"BRepPrimAPI_MakeCylinder": [],
|
"BRepPrimAPI_MakeCylinder": [],
|
||||||
"BRepPrimAPI_MakeHalfSpace": [],
|
"BRepPrimAPI_MakeHalfSpace": [],
|
||||||
"BRepPrimAPI_MakePrism": [],
|
"BRepPrimAPI_MakePrism": [],
|
||||||
"BRepPrimAPI_MakeRevol": []
|
"BRepPrimAPI_MakeRevol": [],
|
||||||
|
"BRepPrimAPI_MakeSphere": []
|
||||||
},
|
},
|
||||||
"BRepTools": {
|
"BRepTools": {
|
||||||
"BRepTools": [
|
"BRepTools": [
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ name = "n3xd-ocp"
|
|||||||
# .devN suffix: the registry never allows republishing a version, and its
|
# .devN suffix: the registry never allows republishing a version, and its
|
||||||
# cleanup rule collects only the dev ones. CMake asserts the prefix matches
|
# cleanup rule collects only the dev ones. CMake asserts the prefix matches
|
||||||
# the OCCT it found.
|
# 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)"
|
description = "nanobind bindings for the OpenCASCADE geometry kernel (drop-in OCP)"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ import sys
|
|||||||
import OCP as _OCP_pkg # noqa: F401 (loads the extension, registering us)
|
import OCP as _OCP_pkg # noqa: F401 (loads the extension, registering us)
|
||||||
|
|
||||||
bintools = sys.modules["n3xd_ocp.bintools"]
|
bintools = sys.modules["n3xd_ocp.bintools"]
|
||||||
|
helix = sys.modules["n3xd_ocp.helix"]
|
||||||
measure = sys.modules["n3xd_ocp.measure"]
|
measure = sys.modules["n3xd_ocp.measure"]
|
||||||
|
sample = sys.modules["n3xd_ocp.sample"]
|
||||||
tess = sys.modules["n3xd_ocp.tess"]
|
tess = sys.modules["n3xd_ocp.tess"]
|
||||||
_debug = sys.modules["n3xd_ocp._debug"]
|
_debug = sys.modules["n3xd_ocp._debug"]
|
||||||
|
|
||||||
__all__ = ["bintools", "measure", "tess"]
|
__all__ = ["bintools", "helix", "measure", "sample", "tess"]
|
||||||
|
|||||||
@@ -18,12 +18,16 @@ namespace nb = nanobind;
|
|||||||
|
|
||||||
void register_ext_bintools();
|
void register_ext_bintools();
|
||||||
void register_ext_debug();
|
void register_ext_debug();
|
||||||
|
void register_ext_helix();
|
||||||
void register_ext_measure();
|
void register_ext_measure();
|
||||||
|
void register_ext_sample();
|
||||||
void register_ext_tess();
|
void register_ext_tess();
|
||||||
|
|
||||||
void register_ext(nb::module_ &) {
|
void register_ext(nb::module_ &) {
|
||||||
register_ext_bintools();
|
register_ext_bintools();
|
||||||
register_ext_debug();
|
register_ext_debug();
|
||||||
|
register_ext_helix();
|
||||||
register_ext_measure();
|
register_ext_measure();
|
||||||
|
register_ext_sample();
|
||||||
register_ext_tess();
|
register_ext_tess();
|
||||||
}
|
}
|
||||||
|
|||||||
125
src/ext/ext_helix.cpp
Normal file
125
src/ext/ext_helix.cpp
Normal 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
122
src/ext/ext_sample.cpp
Normal 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");
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
#include <BRepPrimAPI_MakeHalfSpace.hxx>
|
#include <BRepPrimAPI_MakeHalfSpace.hxx>
|
||||||
#include <BRepPrimAPI_MakePrism.hxx>
|
#include <BRepPrimAPI_MakePrism.hxx>
|
||||||
#include <BRepPrimAPI_MakeRevol.hxx>
|
#include <BRepPrimAPI_MakeRevol.hxx>
|
||||||
|
#include <BRepPrimAPI_MakeSphere.hxx>
|
||||||
#include <TopoDS_Face.hxx>
|
#include <TopoDS_Face.hxx>
|
||||||
#include <TopoDS_Shell.hxx>
|
#include <TopoDS_Shell.hxx>
|
||||||
#include <gp_Ax1.hxx>
|
#include <gp_Ax1.hxx>
|
||||||
@@ -69,6 +70,15 @@ void register_BRepPrimAPI(nb::module_ &root) {
|
|||||||
const Standard_Real>(),
|
const Standard_Real>(),
|
||||||
"Axes"_a, "R1"_a, "R2"_a, "H"_a);
|
"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>(
|
nb::class_<BRepPrimAPI_MakeHalfSpace, BRepBuilderAPI_MakeShape>(
|
||||||
m, "BRepPrimAPI_MakeHalfSpace")
|
m, "BRepPrimAPI_MakeHalfSpace")
|
||||||
.def(nb::init<const TopoDS_Face &, const gp_Pnt &>(), "Face"_a,
|
.def(nb::init<const TopoDS_Face &, const gp_Pnt &>(), "Face"_a,
|
||||||
|
|||||||
118
tests/test_ext_helix.py
Normal file
118
tests/test_ext_helix.py
Normal 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
133
tests/test_ext_sample.py
Normal 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
|
||||||
@@ -32,6 +32,15 @@ HERE = pathlib.Path(__file__).resolve().parent
|
|||||||
DEFAULT_INVENTORY = HERE.parent / "inventory.json"
|
DEFAULT_INVENTORY = HERE.parent / "inventory.json"
|
||||||
DEFAULT_APP = HERE.parent.parent / "app" / "backend"
|
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:
|
def scan(app_root: pathlib.Path) -> dict:
|
||||||
"""Collect {module: {symbol: [attributes reached on it]}} from the app."""
|
"""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]
|
mod, name = local[node.value.id]
|
||||||
modules[mod][name].add(node.attr)
|
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 {
|
return {
|
||||||
"app_root": str(app_root),
|
"app_root": str(app_root),
|
||||||
"files_importing_ocp": files,
|
"files_importing_ocp": files,
|
||||||
|
|||||||
Reference in New Issue
Block a user