n3xd_ocp.tess: a shape's triangulation without the per-node round trip

cad/tessellation.py walks every node and every triangle of every face in
Python — three interpreter loops per face — to turn OCCT's mesh into flat
arrays the kernel already holds. extract_meshes hands them over in one call
with the GIL released; extract_edge_polylines does the same for the per-edge
GCPnts discretisation behind the viewport's edge overlay.

The output reproduces the Python loops exactly, including the parts that look
like quirks: unmeshed faces are omitted so face_index keeps its gaps (it is the
app-wide face identity, not a list position), triangles are rewound and normals
negated for reversed faces, and nodes come back float64 — the binary transport
narrows to float32 on its way out but the JSON one does not, so narrowing here
would quietly change what the REST payload says.

Plain dicts of arrays rather than a bound class, because the cad pool sends
meshes across a pipe and dicts of ndarrays pickle natively. There is a test for
that, and one asserting the arrays outlive the shape.

The equality tests re-implement the Python loops in full and compare with
array_equal, not approx: a difference here moves what the viewport draws or
what a face pick resolves to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfriM8XUkn7uYf5Dwe2xo6
This commit is contained in:
2026-08-10 20:40:25 +02:00
parent 4bb4805ccb
commit 8255fb949c
4 changed files with 423 additions and 1 deletions

View File

@@ -19,9 +19,11 @@ namespace nb = nanobind;
void register_ext_bintools();
void register_ext_debug();
void register_ext_measure();
void register_ext_tess();
void register_ext(nb::module_ &) {
register_ext_bintools();
register_ext_debug();
register_ext_measure();
register_ext_tess();
}

222
src/ext/ext_tess.cpp Normal file
View File

@@ -0,0 +1,222 @@
/*
n3xd_ocp.tess — a shape's triangulation, without the per-node round trip.
cad/tessellation.py walks every node and every triangle of every face in
Python, three interpreter loops per face, to turn OCCT's mesh into flat
arrays. The kernel already holds exactly those arrays; this hands them
over in one call, with the GIL released for the copy.
The output deliberately reproduces what the Python loops produce, down to
the details that look like quirks and are not:
* faces with no triangulation are **omitted**, leaving a gap in the id
sequence. face_index is the MapShapes(FACE) ordinal, which is the face
identity the whole app keys on — picking, anchors, entity maps — so the
gap has to stay.
* triangles are 0-based and **rewound** for a reversed face, so the
winding a renderer sees is the outward one.
* normals are **negated** for a reversed face, for the same reason, and
are None when OCCT computed none (the frontend then derives its own).
* nodes come back float64. The app's binary transport casts to float32
on its way out, but its JSON transport does not, so narrowing here
would quietly change what the REST payload says.
Edges get the same treatment: one call replaces a per-edge
GCPnts_TangentialDeflection plus a per-point Python loop.
*/
#include "../common/occt_module.h"
#include <nanobind/ndarray.h>
#include <BRepAdaptor_Curve.hxx>
#include <BRep_Tool.hxx>
#include <GCPnts_TangentialDeflection.hxx>
#include <Poly_Triangulation.hxx>
#include <TopAbs_ShapeEnum.hxx>
#include <TopExp.hxx>
#include <TopLoc_Location.hxx>
#include <TopTools_IndexedMapOfShape.hxx>
#include <TopoDS.hxx>
#include <TopoDS_Edge.hxx>
#include <TopoDS_Face.hxx>
#include <TopoDS_Shape.hxx>
#include <gp_Pnt.hxx>
#include <gp_Trsf.hxx>
#include <vector>
namespace {
template <typename T> nb::capsule owner_of(T *data) {
return nb::capsule(data, [](void *p) noexcept { delete[] (T *) p; });
}
/// Wrap an owned buffer as an (n, cols) numpy array.
template <typename T>
nb::object as_matrix(T *data, size_t rows, size_t cols) {
size_t shape[2] = {rows, cols};
return nb::cast(
nb::ndarray<nb::numpy, T, nb::ndim<2>>(data, 2, shape, owner_of(data)));
}
/// One face's mesh, already flattened and oriented, ready to hand to Python.
struct FaceMesh {
int index = 0;
std::vector<double> nodes; // 3N
std::vector<double> normals; // 3N, empty when OCCT computed none
std::vector<uint32_t> tris; // 3M
};
} // namespace
void register_ext_tess() {
nb::module_ m = ocp_named_module("n3xd_ocp.tess");
m.def(
"extract_meshes",
[](const TopoDS_Shape &shape, bool want_normals, bool apply_location,
bool flip_reversed) {
TopTools_IndexedMapOfShape faces;
TopExp::MapShapes(shape, TopAbs_FACE, faces);
std::vector<FaceMesh> meshes;
{
nb::gil_scoped_release nogil;
for (int i = 1; i <= faces.Extent(); ++i) {
const TopoDS_Face &face = TopoDS::Face(faces.FindKey(i));
TopLoc_Location loc;
Handle(Poly_Triangulation) tri = BRep_Tool::Triangulation(face, loc);
if (tri.IsNull())
continue; // keeps the id gap the app relies on
const bool reversed =
flip_reversed && face.Orientation() == TopAbs_REVERSED;
const double sign = reversed ? -1.0 : 1.0;
const gp_Trsf trsf = loc.Transformation();
const bool identity = !apply_location || loc.IsIdentity();
const bool normals =
want_normals && tri->HasNormals();
FaceMesh mesh;
mesh.index = i - 1;
mesh.nodes.resize(3 * (size_t) tri->NbNodes());
if (normals)
mesh.normals.resize(3 * (size_t) tri->NbNodes());
mesh.tris.resize(3 * (size_t) tri->NbTriangles());
for (int n = 1; n <= tri->NbNodes(); ++n) {
gp_Pnt point = tri->Node(n);
if (!identity)
point.Transform(trsf);
mesh.nodes[3 * (n - 1) + 0] = point.X();
mesh.nodes[3 * (n - 1) + 1] = point.Y();
mesh.nodes[3 * (n - 1) + 2] = point.Z();
if (normals) {
gp_Dir normal = tri->Normal(n);
if (!identity)
normal = normal.Transformed(trsf);
mesh.normals[3 * (n - 1) + 0] = sign * normal.X();
mesh.normals[3 * (n - 1) + 1] = sign * normal.Y();
mesh.normals[3 * (n - 1) + 2] = sign * normal.Z();
}
}
for (int t = 1; t <= tri->NbTriangles(); ++t) {
Standard_Integer a = 0, b = 0, c = 0;
tri->Triangle(t).Get(a, b, c);
mesh.tris[3 * (t - 1) + 0] = (uint32_t) (a - 1);
if (reversed) {
mesh.tris[3 * (t - 1) + 1] = (uint32_t) (c - 1);
mesh.tris[3 * (t - 1) + 2] = (uint32_t) (b - 1);
} else {
mesh.tris[3 * (t - 1) + 1] = (uint32_t) (b - 1);
mesh.tris[3 * (t - 1) + 2] = (uint32_t) (c - 1);
}
}
meshes.push_back(std::move(mesh));
}
}
nb::list out;
for (const FaceMesh &mesh : meshes) {
const size_t n = mesh.nodes.size() / 3;
const size_t t = mesh.tris.size() / 3;
double *nodes = new double[mesh.nodes.size()];
std::copy(mesh.nodes.begin(), mesh.nodes.end(), nodes);
uint32_t *tris = new uint32_t[mesh.tris.size()];
std::copy(mesh.tris.begin(), mesh.tris.end(), tris);
nb::dict entry;
entry["face_index"] = mesh.index;
entry["nodes"] = as_matrix(nodes, n, 3);
entry["triangles"] = as_matrix(tris, t, 3);
if (mesh.normals.empty()) {
entry["normals"] = nb::none();
} else {
double *normals = new double[mesh.normals.size()];
std::copy(mesh.normals.begin(), mesh.normals.end(), normals);
entry["normals"] = as_matrix(normals, n, 3);
}
out.append(entry);
}
return out;
},
"shape"_a, nb::kw_only(), "want_normals"_a = true,
"apply_location"_a = true, "flip_reversed"_a = true,
R"doc(Every triangulated face's mesh, as a list of dicts.
Keys: ``face_index`` (0-based MapShapes(FACE) ordinal), ``nodes`` (float64
[N,3]), ``triangles`` (uint32 [M,3], 0-based), ``normals`` (float64 [N,3] or
None). Faces OCCT has not meshed are omitted, so face_index has gaps it is
the app-wide face identity, not a position in this list.)doc");
m.def(
"extract_edge_polylines",
[](const TopoDS_Shape &shape, double angular, double linear) {
TopTools_IndexedMapOfShape edges;
TopExp::MapShapes(shape, TopAbs_EDGE, edges);
std::vector<std::pair<int, std::vector<double>>> polylines;
{
nb::gil_scoped_release nogil;
for (int i = 1; i <= edges.Extent(); ++i) {
try {
BRepAdaptor_Curve curve(TopoDS::Edge(edges.FindKey(i)));
GCPnts_TangentialDeflection points(curve, angular, linear);
std::vector<double> flat;
flat.reserve(3 * (size_t) points.NbPoints());
for (int p = 1; p <= points.NbPoints(); ++p) {
const gp_Pnt point = points.Value(p);
flat.push_back(point.X());
flat.push_back(point.Y());
flat.push_back(point.Z());
}
polylines.emplace_back(i - 1, std::move(flat));
} catch (const Standard_Failure &) {
// A curve that will not discretise is skipped, which
// leaves the same id gap the Python path leaves.
}
}
}
nb::list out;
for (const auto &entry : polylines) {
double *points = new double[entry.second.size()];
std::copy(entry.second.begin(), entry.second.end(), points);
nb::dict row;
row["edge_index"] = entry.first;
row["points"] = as_matrix(points, entry.second.size() / 3, 3);
out.append(row);
}
return out;
},
"shape"_a, "angular"_a, "linear"_a,
R"doc(Every edge discretised to a polyline.
Returns dicts of ``edge_index`` (0-based MapShapes(EDGE) ordinal) and
``points`` (float64 [P,3]). An edge that will not discretise is skipped,
leaving the same gap the per-edge Python loop leaves.)doc");
}