Skip to content

Mesh geometry import#74

Open
Luochenghuang wants to merge 8 commits intoNanoComp:masterfrom
Luochenghuang:mesh-geometry-import
Open

Mesh geometry import#74
Luochenghuang wants to merge 8 commits intoNanoComp:masterfrom
Luochenghuang:mesh-geometry-import

Conversation

@Luochenghuang
Copy link
Copy Markdown
Contributor

@Luochenghuang Luochenghuang commented Apr 7, 2026

Summary

Closes #67. Adds a new MESH geometric object type for importing arbitrary triangulated 3D surfaces (e.g., from STL files) into libctl's geometry system.

Design

The mesh follows the same tagged-union dispatch as SPHERE, CYLINDER, BLOCK, and PRISM. A flat-array BVH with SAH binning accelerates all queries to O(log N).

All seven core operations are implemented:

Operation Implementation
point_in_mesh Ray casting with lazy 3-ray majority vote (Moller-Trumbore + BVH)
normal_to_mesh BVH nearest-face search (Eberly closest-point-on-triangle)
intersect_line_segment_with_mesh Sorted ray-mesh intersections with even-odd parity accumulation
get_mesh_volume Divergence theorem (signed tetrahedron volumes)
get_mesh_bounding_box Cached from BVH root node
display_mesh_info Vertex/face counts and closure status
init_mesh Validates, computes normals, checks closure, fixes winding, builds BVH

Meep's subpixel smoothing works automatically. No meep C++ changes required.

API

// Closed triangulated 3D mesh. The mesh must be watertight (every edge
// shared by exactly 2 faces); open meshes are detected and produce a warning.
geometric_object make_mesh(material_type material,
                           const vector3 *vertices, int num_vertices,
                           const int *triangles, int num_triangles);

geometric_object make_mesh_with_center(material_type material, vector3 center,
                                       const vector3 *vertices, int num_vertices,
                                       const int *triangles, int num_triangles);

Robustness

  • Lazy majority vote: casts 1 ray; only falls back to 3-ray vote when deduplication detects a shared-edge hit. Correct and fast for the common case.
  • Mesh closure validation: edge-adjacency hash table detects boundary and non-manifold edges. point_in_mesh returns false for open meshes.
  • reinit_mesh guard: skips redundant BVH rebuilds in geom_fix_object_ptr (mesh uses absolute coordinates, not lattice-relative).

Test plan

  • 16 unit tests: point-in, volume, bbox, normals, line-segment intersection, copy/destroy, closure detection (open, boundary edge, non-manifold, isolated vertex)
  • Cube mesh vs Block primitive: 10K random points, 0 mismatches

Implement a new MESH primitive alongside SPHERE, CYLINDER, BLOCK, and
PRISM. A mesh is defined by a vertex array and triangle index array,
with a BVH (bounding volume hierarchy) for O(log N) queries.

Core operations implemented:
- point_in_mesh: ray casting with parity counting and deduplication
- normal_to_mesh: BVH-accelerated closest-face search
- get_mesh_bounding_box: from BVH root node
- get_mesh_volume: divergence theorem (signed tetrahedron volumes)
- intersect_line_segment_with_mesh: for subpixel smoothing integration
- init_mesh: validates geometry, computes normals, fixes winding, builds BVH

BVH uses SAH (surface area heuristic) binned construction with flat
array layout. Ray-triangle intersection uses Moller-Trumbore algorithm.
Closest-point queries use Eberly's algorithm with BVH pruning.

Also adds make_mesh/make_mesh_with_center constructors, mesh
copy/equal/destroy in geom-ctl-io.c, and 11 unit tests covering
point-in, volume, bbox, normals, line-segment intersection, and
comparison against the Block primitive.
Two fixes:

1. point_in_mesh: use 3-ray majority vote instead of a single ray.
   A single ray can pass through a shared mesh edge, causing the
   deduplication to either miss or double-count a crossing and flip
   the inside/outside parity. Three rays in irrational directions
   make it extremely unlikely that more than one hits a degenerate
   case at the same query point.

2. reinit_mesh: skip BVH rebuild if already initialized. Unlike
   blocks/cylinders, mesh uses absolute coordinates and doesn't
   depend on the lattice basis. geom_fix_object_ptr is called
   hundreds of times during meep's init_sim via geometry copies;
   without the guard, each call rebuilt the BVH (~150ms for 9K
   triangles), causing ~40s of wasted time.
- point_in_mesh: cast 1 ray first, only fall back to 3-ray majority
  vote if deduplication removed near-duplicate t-values (indicating
  an edge/vertex hit). 3x faster for the common case (no edge hits).

- init_mesh: validate mesh closure by building an edge-to-face-count
  hash table. Every edge must be shared by exactly 2 faces. Sets
  is_closed=false and prints a warning for open/non-manifold meshes.
  point_in_mesh returns false for all points on open meshes.

- Add 2 new tests: open mesh detection, closed mesh detection (13
  tests total, all passing).
Test boundary edges (1 face per edge), non-manifold edges (4 faces
sharing an edge), and isolated vertices (vertex unreferenced by any
face). 16 tests total, all passing.
1. make_mesh_with_center: shift vertices before calling init_mesh
   so the BVH is only built once (was building twice when center
   was specified).

2. intersect_line_segment_with_mesh: determine inside/outside at
   the segment start from the full intersection list parity
   (count crossings before parameter 'a') instead of calling
   point_in_mesh as a fallback. Eliminates millions of extra ray
   casts during subpixel smoothing. ~20% faster set_epsilon on
   the Utah teapot (28.6s → 22.9s).
- Remove unused bvh_node_box helper function
- find_closest_face: push nearer BVH child last so it's popped first,
  improving pruning and reducing nodes visited for normal queries
- Extract mesh_triangle_vertices helper to avoid redundant vertex
  fetches. Add mesh_triangle_bbox_centroid to compute both in one
  pass during BVH binning (was fetching 3 vertices twice per face)
These files are normally auto-generated from geom.scm via gen-ctl-io
(requires Guile). Including them allows building libctlgeom without
Guile, which is the common case for meep's Python-only builds.
case 1: lo = node_box.low.y; hi = node_box.high.y; break;
default: lo = node_box.low.z; hi = node_box.high.z; break;
}
if (hi - lo < 1e-15) continue;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be 1e-15 * lengthscale where lengthscale is some characteristic scale, e.g. the diameter of the whole objects.

Similarly, parent_area = 1e-30 * lengthscale * lengthscale.

vector3 n = vector3_cross(e1, e2);
double len = vector3_norm(n);
m->face_areas[f] = 0.5 * len;
m->face_normals[f] = (len > 1e-30) ? vector3_scale(1.0 / len, n) : n;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These "magic small numbers" should be relative to a lengthscale, e.g. the diameter of the object.

vector3 e2 = vector3_minus(v2, v0);
vector3 n = vector3_cross(e1, e2);
double len = vector3_norm(n);
m->face_areas[f] = 0.5 * len;
Copy link
Copy Markdown
Collaborator

@stevengj stevengj Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be good to document that $n = (v_1 - v_0) \times (v_2 - v_0)$ should all point outwards or all point inwards on input (i.e. the faces should be oriented consistently), and that internally it normalizes them to all point outwards.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

support general polyhedra from triangular meshes

2 participants