src/mesh/README.md in the repository — edit it there.Mesh — Triangle Geometry, Loaded, Checked 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; the repair pass vets it; analysis reads it; transforms produce new copies of it; the slicer consumes it.
The contract
- Coordinates are mm; Z is up. Loaders convert from whatever the source uses (STL
f32, OBJ unspecified) tof64in this convention. Slicing, G-code, and the scene engine all assume it. - Transforms are pure.
translate_mesh,scale_mesh, and friends return a newMesh; the input is never mutated. The new mesh's cachedaabbis cleared so it is recomputed on next access. Facecarries its own vertex copies. The triangles are denormalised for slicing speed: eachFacestores its threeVertexvalues inline, not indices intoMesh::vertices. Theverticesarray exists for AABB computation; the slicer readsfaces.- Every load is validated, and defects are repaired.
repair::repairruns insidescene::loaderbefore the mesh reaches anything else. Its own contract is that a clean mesh is returned untouched — literallyCow::Borrowed, not a rebuilt copy — so this changes nothing for well-formed models. See Validation and repair. - Loaders never apply placement 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). The one exception is 3MF: its
<build>item and<components>transforms are baked in, because they assemble the object's own geometry (each mesh's triangle indices are local to that mesh), not a user placement on the bed.
Anatomy
A few things worth knowing:
aabbis a cache. It isNoneafter construction or after a transform, and gets populated lazily byanalysis::calculate_aabb.Face::normalis what the file said. STL ASCII files often record zero vectors as normals; in those cases the field isNoneand the slicer recomputes orientation from the triangle winding when it needs to.- No persistent half-edge / connectivity structure. The slicer uses Clipper2 to chain segments after intersection, so it never needs to know which triangles share which edges.
repairdoes build an edge graph, but it is transient — thrown away before the mesh is handed on.
Analysis functions
analysis.rs provides three categories of mesh inspection:
| Function | Returns | Notes |
|---|---|---|
calculate_aabb | AABB | Scans all vertices; panics on empty mesh |
calculate_volume | Result<f64, String> | Divergence-theorem signed sum; returns Err if no faces |
calculate_surface_area | f64 | Sums Face::area() over all triangles |
compute_coplanar_groups | Vec<u32> (one id per face) | Region growing 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. A group grows from a seed triangle across shared edges, taking in each connected triangle whose normal agrees with the seed's within angle_threshold_deg.
Measuring against the seed, not the neighbour, is the rule that matters. A CAD export tessellates a curve finely enough that every neighbouring pair is under a degree apart, so a neighbour-to-neighbour test chains all the way round the part and one "flat face" ends up covering half the model.
Edge adjacency is the same face_adjacency graph the support-paint brush walks: vertex positions are quantised to a vertex_merge_distance_mm grid so floating-point near-duplicates share an edge. Degenerate triangles have a zero normal and never join a group.
The returned Vec<u32> is contiguous — group ids start at 0 and are assigned in seed order, which is face order, so the result is deterministic. The WASM bridge exposes this as SceneHandle.getFaceGroups(id, angleThresholdDeg), which the UI uses for face-highlight in the pullToFloor gizmo mode.
Tuning knobs
| Parameter | Recommended value | Effect |
|---|---|---|
angle_threshold_deg | 1.0° | Larger = merges slightly uneven surfaces |
vertex_merge_distance_mm | 0.001 mm | Larger = tolerates worse vertex welding |
Validation and repair
Real STLs are frequently broken. The slicer used to absorb that silently: slice_mesh chains open contour segments defensively, so a hole in the model turned into a missing top surface three stages later, with nothing pointing back at the cause. repair.rs exists to name the problem at the door, and fix it where it safely can.
Why it can be on by default
Because it is a no-op on a clean mesh, by construction. repair measures first; when the diagnostics come back clean it hands the original mesh straight back as Cow::Borrowed and never rebuilds anything. Every model in this repository — Benchy, the Voron cube, the caddy, the hinge, the simple-cube fixtures in all four formats — is clean, which is what lets the slicing-quality baselines stay valid with repair enabled everywhere. That is pinned by known_good_meshes_are_reported_clean_and_never_rewritten in ../../tests/mesh_repair.rs; if it ever fails, the baselines are about to drift.
Determinism is a correctness requirement, not a nicety
SceneOp::PlaceFaceOnFloor { face_index } is picked in the browser against the mesh the wasm bundle parsed, and — in cloud mode — re-resolved by a server that loaded and repaired the same file independently. The two must agree, so repair assigns vertex and face indices in strict first-seen order and its union–find always attaches the larger root to the smaller, never depending on hash iteration order.
What it measures
MeshDiagnostics is computed on the welded triangle graph. Degenerate triangles are counted but excluded from the edge graph, where their repeated corners would fabricate defects that aren't there.
| Field | Meaning |
|---|---|
degenerate_faces | Repeated corner, or effectively zero area |
duplicate_faces | Repeats another triangle's corner set |
boundary_edges / holes | Edges used once, chained into loops that enclose real area |
slit_boundary_edges | Edges used once whose loop encloses no area |
non_manifold_edges | More than two incident triangles |
inconsistent_winding_edges | Both triangles traverse the shared edge the same way |
inverted_shells | Closed shell with negative signed volume |
shells | Connected components across shared edges |
What it repairs
- Welding is two-stage on purpose. The exact pass is one hash lookup per corner, so a clean 225k-triangle model pays almost nothing. The expensive 27-neighbour tolerance pass is restricted to vertices that actually touch a boundary edge — a crack is exactly where welding matters, and confining it there makes accidentally merging distinct interior geometry impossible.
- Duplicates are grouped by corner set. When one orientation outnumbers the other, one triangle in the majority orientation survives. When they cancel exactly — the classic zero-volume "flap" — the whole group goes, because keeping one would tear three edges open.
- Winding propagates by BFS across manifold edges; non-manifold edges are not traversed, since which side is "outside" there is ambiguous. Each shell is then flipped as a unit if its signed volume is negative — but only if that shell is closed. Signed volume here is the divergence-theorem cone volume about the origin, which equals the enclosed volume only for a sealed surface; on an open one the sum is dominated by the cone over the missing region and its sign depends on where the model happens to sit in space. Orienting an open shell by it would turn a perfectly good surface inside out. Open shells therefore keep whatever the BFS produced, and if
fill_holesseals them the pass runs again and orients them then. Pinned byan_uncappable_hole_never_inverts_the_surface. - A zero-area boundary loop is a slit, not a hole, and is never patched. A T-junction (or the rim left by a zero-area sliver) produces collinear open edges enclosing nothing: the surface bounds exactly the same solid with or without them, and any patch across one would itself be zero-area — so it would close nothing (
diagnoseexcludes degenerate triangles from the edge graph) while adding junk geometry. This is not a corner case: a real 225 706- triangle Benchy export has nine of them, and an earlier version of this pass added 15 useless triangles to it and then reported it as permanently defective. Slits are counted separately, reported for information only, and never make a mesh "unclean". Pinned bya_zero_area_slit_is_not_a_hole_and_is_never_patched. - Hole capping reuses each boundary half-edge in reverse, so the patch is consistently wound with the surface it closes without any extra reasoning. A 3-edge loop becomes one triangle; anything larger is fanned from a new centroid vertex, which is watertight regardless of how non-planar the rim is. Loops longer than
max_hole_edges(512) are reported and left alone — an intentionally open surface must not be "repaired" shut. Capping can leave a previously-open shell inverted (an open surface has no meaningful volume), so the winding pass gets a second look afterwards.
Where the report goes
MeshReport (before / after / actions / summary) reaches the user through every runtime, worded identically by repair::log_report:
| Runtime | Surface |
|---|---|
| CLI | Warning line during slice, a mesh block in --output-format json, and the dedicated slicer-engine mesh-check command |
| Cloud | WsLogger::log_warn from handle_slice, relayed into the UI log |
| Native | The same warning through the Tauri bridge's process logger |
| UI | A warning toast raised once per import from SceneEngine.addMesh, reading SceneHandle.meshReport(id) |
Opting out is RepairOptions::analysis_only() in Rust and --no-mesh-repair on the CLI. The UI always repairs.
Every part of a multi-part file is validated on its own. A 3MF is a scene, not a model, so load_*_multi_reporting repairs each build item separately and returns a LoadedPart carrying its own report — one defective part says nothing about its siblings. Pinned by multi_part_loading_validates_every_part.
File-format catalog
| Format | Variant | Loader | Notes |
|---|---|---|---|
| STL | Binary | io::read_stl | Via stl_io; fastest path, normals usually present |
| STL | ASCII | io::read_stl | Same entry point; stl_io auto-detects |
| OBJ | Wavefront | io::read_obj | Via tobj; vertex positions only, materials ignored |
| 3MF | XML-in-ZIP | io::read_3mf | Custom parse (zip + quick-xml); merges all <build> items, rebasing each object's local indices and baking item/component transforms |
Going the other way, io::write_3mf writes a plate: one <object> per distinct mesh — duplicates sharing an Arc<Mesh> share it — and one build item per scene object, carrying that object's transform rather than baking it in, so another slicer opens the same layout. The scene calls it through SceneState::export_3mf. It is the only writer; 3MF is the one format that can say where a model sits.
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
- Load.
io::read_*(orscene::loader::load_bytes) parses the file into aMeshwithaabb: None. - Validate.
repair::repairmeasures the triangle graph and, unless the caller opted out, fixes what it can. A clean mesh is passed straight through; a defective one is replaced by a repaired copy and aMeshReportis surfaced to the user. - Inspect.
analysis::calculate_aabb,calculate_volume,calculate_surface_areapopulate / report basic geometry. The first AABB call fills the cache. - Place. The scene engine wraps the mesh in
Arc<Mesh>and tracks aTransformalongside it. The mesh itself is not mutated by scene ops. - Bake. Just before
core::process_mesh,scene::transform::apply_transformproduces a newMeshwith the transform baked into the vertices, AABB cleared. - Slice.
core::slice_meshwalks the (now world-space) faces and emitsSliceLayers.
After step 6 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 deep mesh repair. repair.rs welds cracks, drops degenerate and duplicate triangles, unifies winding and caps simple holes. It deliberately stops there: non-manifold edges are reported but not split, and self-intersections, T-junctions and shell separation are not touched at all. Those change the surface in ways that need their own design.
- No general format conversion. The only mesh writer is
write_3mf, for exporting a plate; there is nowrite_stlorwrite_obj. - No retained connectivity graph. The slicer doesn't need one, and keeping it would cost memory we don't have on wasm32. Repair builds one and drops it again within a single call.
See also
- types.rs —
Mesh,Face,Vertex,AABB - io.rs — STL / OBJ / 3MF loaders, the 3MF writer,
SUPPORTED_EXTENSIONS - analysis.rs — AABB, volume, surface area, coplanar face groups
- repair.rs — validation, diagnostics, and the auto-repair pass
- ../../tests/mesh_repair.rs — the known-bad corpus and the clean-mesh no-op contract
- ../../tests/fixtures/broken/ — one defective cube per defect class, regenerated by
generate.py - transforms.rs — pure translate / scale / rotate helpers
- ../scene/README.md — how meshes are placed in a scene
- ../SLICING.md — the triangle-plane intersection algorithm
- ../scene/loader.rs — the one funnel every runtime loads through