Skip to content

Source: src/mesh/README.md — this page is rendered directly from the file in the repository. Edit it there.

Mesh — Triangle Geometry, Loaded and Inspected

This module owns one thing: the in-memory triangle mesh that the rest of the engine slices. Everything starts with a Mesh, and every byte that comes in from disk — STL, OBJ, 3MF — funnels through here on the way to becoming one.

All coordinates are in millimeters. Z is up. There is no other convention.


Why it exists

Three distinct concerns share one truth: a soup of triangles with a bounding box.

  • The slicer wants a flat list of triangles to intersect with horizontal planes — it doesn't care how they got there.
  • The scene engine (../scene/) wants to clone meshes cheaply (via Arc<Mesh>) and bake transforms once before slicing.
  • The loaders want a single target type to convert into, regardless of whether the source is binary STL, ASCII STL, OBJ, or a ZIP-wrapped 3MF.

mesh::types::Mesh is that single target. Loaders fill it; analysis reads it; transforms produce new copies of it; the slicer consumes it.


The contract

  1. Coordinates are mm; Z is up. Loaders convert from whatever the source uses (STL f32, OBJ unspecified) to f64 in this convention. Slicing, G-code, and the scene engine all assume it.
  2. Transforms are pure. translate_mesh, scale_mesh, and friends return a new Mesh; the input is never mutated. The new mesh's cached aabb is cleared so it is recomputed on next access.
  3. Face carries its own vertex copies. The triangles are denormalised for slicing speed: each Face stores its three Vertex values inline, not indices into Mesh::vertices. The vertices array exists for AABB computation; the slicer reads faces.
  4. Loaders never apply transforms. A loaded mesh is in the file's native coordinates. Centering, dropping to floor, rotating — all of that is the scene engine's job (../scene/ops.rs).

Anatomy

A few things worth knowing:

  • aabb is a cache. It is None after construction or after a transform, and gets populated lazily by analysis::calculate_aabb.
  • Face::normal is what the file said. STL ASCII files often record zero vectors as normals; in those cases the field is None and the slicer recomputes orientation from the triangle winding when it needs to.
  • No half-edge / connectivity structure. This is deliberate — the slicer uses Clipper2 to chain segments after intersection, so we never need to know which triangles share which edges.

Analysis functions

analysis.rs provides three categories of mesh inspection:

FunctionReturnsNotes
calculate_aabbAABBScans all vertices; panics on empty mesh
calculate_volumeResult<f64, String>Divergence-theorem signed sum; returns Err if no faces
calculate_surface_areaf64Sums Face::area() over all triangles
compute_coplanar_groupsVec<u32> (one id per face)Union-find over shared edges; see below

Coplanar face groups

compute_coplanar_groups(mesh, angle_threshold_deg, vertex_merge_distance_mm) assigns every triangle to a coplanar group. Two triangles end up in the same group when they share an edge and their geometric normals agree within angle_threshold_deg. The algorithm runs in three phases:

  1. Normal computation. Each face gets a unit geometric normal (cross product, then normalised). Degenerate triangles (zero-length cross product) get the zero vector and never merge with anything.

  2. Edge adjacency. Vertex positions are quantised to a vertex_merge_distance_mm grid so floating-point near-duplicates collapse to the same integer key. Every directed half-edge is then hashed to a symmetric key (min_vert, max_vert). Half-edges are sorted by key, giving an O(N log N) pass to collect all faces that share each edge.

  3. Union-find merge. For every set of faces that share an edge, each pair is tested: if dot(normalA, normalB) ≥ cos(threshold), the two faces are joined. Path-halving and union-by-rank keep the structure nearly flat.

The returned Vec<u32> is contiguous — group ids start at 0 and are assigned in the order the first face of each group is encountered. The WASM bridge exposes this as SceneHandle.getFaceGroups(id, angleThresholdDeg), which the UI uses for face-highlight in the pullToFloor gizmo mode.

Tuning knobs

ParameterRecommended valueEffect
angle_threshold_deg1.0°Larger = merges slightly uneven surfaces
vertex_merge_distance_mm0.001 mmLarger = tolerates worse vertex welding

File-format catalog

FormatVariantLoaderNotes
STLBinaryio::read_stlVia stl_io; fastest path, normals usually present
STLASCIIio::read_stlSame entry point; stl_io auto-detects
OBJWavefrontio::read_objVia tobj; vertex positions only, materials ignored
3MFXML-in-ZIPio::read_3mfCustom parse (zip + quick-xml); first object only

SUPPORTED_EXTENSIONS lists the recognised file extensions for CLI / WS validation. The scene loader (../scene/loader.rs) dispatches on MeshFormat rather than re-sniffing extensions.


Role in the wider system

The mesh module never knows about scenes, but the scene module relies on this contract: cheap Arc<Mesh> clones, pure transforms, and a single AABB cache slot that transforms invalidate.


Lifecycle of a single mesh

  1. Load. io::read_* (or scene::loader::load_bytes) parses the file into a Mesh with aabb: None.
  2. Inspect. analysis::calculate_aabb, calculate_volume, calculate_surface_area populate / report basic geometry. The first AABB call fills the cache.
  3. Place. The scene engine wraps the mesh in Arc<Mesh> and tracks a Transform alongside it. The mesh itself is not mutated by scene ops.
  4. Bake. Just before core::process_mesh, scene::transform::apply_transform produces a new Mesh with the transform baked into the vertices, AABB cleared.
  5. Slice. core::slice_mesh walks the (now world-space) faces and emits SliceLayers.

After step 5 the original Arc<Mesh> is still alive and unchanged in SceneState — re-slicing with a different transform reuses it.


What this module deliberately does not do

  • No placement logic. Centering, dropping to floor, face alignment — all in ../scene/. The scene engine is the SSOT for "where is it"; this module is the SSOT for "what is it".
  • No mesh repair. Holes, non-manifold edges, flipped normals — out of scope. The slicer assumes a watertight, consistently-wound mesh and the loaders pass through whatever the file contains.
  • No format conversion. read_stl produces a Mesh; there is no write_stl. Outputs are G-code, not meshes.
  • No connectivity graph. The slicer doesn't need it; building one would cost memory we don't have on wasm32.

See also