Mesh export: the faces of an edge share its points exactly (#1753)

Thanks @SychPL.
This commit is contained in:
Mateusz Sych authored and GitHub committed 2026-10-16 12:26:14 -04:00
1 parent a8a3cea1d0
commit 74ce904142
3 files changed
+130 -1

No files matched your search

@@ -0,0 +1,77 @@
//! Mesh export regressions: a closed body exports as a closed mesh.
use std::collections::HashMap;
use serde_json::{Value, json};
use solidcraft_engine::Session;
use solidcraft_io::{Format, export, read_3mf, read_stl};
fn run(s: &mut Session, script: Value) {
for c in script.as_array().into_iter().flatten() {
let (id, p) = (c["command"].as_str().unwrap_or_default(), &c["params"]);
if let Err(e) = s.execute(id, p) {
panic!("{id} {p}: {e}");
}
}
}
/// The edges (by vertex index) that are not used by exactly two triangles.
fn open_edges(triangles: &[[u32; 3]]) -> Vec<((u32, u32), u32)> {
let mut uses: HashMap<(u32, u32), u32> = HashMap::new();
for t in triangles {
for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
*uses.entry((a.min(b), a.max(b))).or_default() += 1;
}
}
uses.into_iter().filter(|(_, n)| *n != 2).collect()
}
/// A bowl section (lines and arcs) revolved about Z, its rim cut by a large cylinder across it.
/// The edge where the cylinder meets the inner cone is an intersection curve; one of its samples
/// lay a few ten-thousandths of a millimetre off both surfaces, each face put the point on its
/// own surface, and the exported mesh had a crack there (four edges used once).
#[test]
fn a_rim_cut_by_a_cylinder_exports_closed() {
let mut s = Session::default();
run(
&mut s,
json!([
{"command": "sketch.create", "params": {"plane": "XZ", "name": "Section"}},
{"command": "sketch.line", "params": {"points": [[0, 0], [48.8, 0], [50.0240096077, -1.2]]}},
{"command": "sketch.arc.three_point", "params": {"start": [50.0240096077, -1.2], "end": [58.7867965644, -21.2132034356], "through": [52.5188160894, -12.0326443841]}},
{"command": "sketch.line", "params": {"points": [[58.7867965644, -21.2132034356], [85.5735931288, -48], [82.1794805791, -48], [57.0897402896, -22.9102597104]]}},
{"command": "sketch.arc.three_point", "params": {"start": [57.0897402896, -22.9102597104], "end": [48.3846976331, -7.0875], "through": [51.6125175266, -15.6176451113]}},
{"command": "sketch.arc.three_point", "params": {"start": [48.3846976331, -7.0875], "end": [42.5300120096, -2.4], "through": [46.2800120096, -3.7162515012]}},
{"command": "sketch.line", "params": {"points": [[42.5300120096, -2.4], [0, -2.4], [0, 0]]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.revolve", "params": {"sketch": "Section", "axis": "Z"}},
{"command": "sketch.create", "params": {"plane": "YZ", "name": "RimCutter"}},
{"command": "sketch.arc.three_point", "params": {"start": [-100, 11.6204810674], "end": [100, 46.4045454912], "through": [-52, 8]}},
{"command": "sketch.line", "params": {"points": [[100, 46.4045454912], [100, 70], [-100, 70], [-100, 11.6204810674]]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"sketch": "RimCutter", "distance": 200, "direction": "symmetric", "operation": "cut"}}
]),
);
let st = s.model.state();
assert_eq!(st.bodies.len(), 1);
let (objects, _) = read_3mf(&export(&st, &[], Format::ThreeMf, "cove").unwrap()).unwrap();
assert_eq!(objects.len(), 1);
let open = open_edges(&objects[0].triangles);
assert!(open.is_empty(), "3MF: {} edges not used twice, e.g. {:?}", open.len(), open.first());
// STL has no indices: vertices are the same where their coordinates are.
let mut index: HashMap<[u32; 3], u32> = HashMap::new();
let triangles: Vec<[u32; 3]> = read_stl(&export(&st, &[], Format::StlBinary, "cove").unwrap())
.unwrap()
.iter()
.map(|t| {
t.map(|p| {
let n = index.len() as u32;
*index.entry(p.map(f32::to_bits)).or_insert(n)
})
})
.collect();
let open = open_edges(&triangles);
assert!(open.is_empty(), "STL: {} edges not used twice, e.g. {:?}", open.len(), open.first());
}
+9
View File
@@ -154,6 +154,15 @@ Changes:
p11`, across that plane, at the middle). A bilinear patch equals the blend of its corners the
division compared against, so a twisted ruled face (a loft between polylines) went out as two
triangles per face (#1666); a flat cell such as a cone's trapezoid between rulings is not split.
- `tessellation/triangulation.rs`: a shell's face meshes share their edges' points exactly. A
face's mesh puts each point at its parameters on the face's own surface, so a point of an edge
that lies off a surface within tolerance (an intersection-curve sample whose projection did not
converge, an imported edge) came out in a different place on each of the edge's faces, and the
exported mesh had a crack there. In display and export meshes (`Deflection::rulings`), a
vertex at a point of a face's boundary loops that is farther than `TOLERANCE` from the nearest
point of the face's edges now goes to that point, when it is within the chord tolerance
(`onto_edges`). Points within `TOLERANCE` stay on the surface, so exact geometry meshes as
before. The meshes Boolean operations intersect keep truck's points.
## matext4cgmath 0.1.0 (Apache-2.0, © RICOS Co. Ltd., https://github.com/ricosjp/matext4cgmath)
+44 -1
View File
@@ -389,7 +389,14 @@ fn shell_create_polygon<S: PreMeshableSurface>(
.collect::<Option<Vec<_>>>();
let polygon: Option<PolygonMesh> = (|| {
let boundary = PolyBoundary::new(preboundary?, &surface, tol);
Some(trimming_tessellation(surface, &boundary, tol, cap))
let mut mesh = trimming_tessellation(surface, &boundary, tol, cap);
// Display and export meshes only, as the ruling grid lines: the meshes Boolean
// operations intersect keep truck's points.
if tol.rulings {
let points: Vec<Point3> = wires.iter().flat_map(Wire::edge_iter).flat_map(|e| e.curve().0).collect();
onto_edges(&mut mesh, &boundary, &points, tol.chord);
}
Some(mesh)
})();
// SolidCraft: faces with seam edges (a loop running along an edge both ways, as STEP
// writes cylinders) are valid here; `debug_new` asserts simple loops in debug builds.
@@ -400,6 +407,42 @@ fn shell_create_polygon<S: PreMeshableSurface>(
new_face
}
/// SolidCraft: a face's mesh puts every point where its own surface passes the point's
/// parameters, and a boundary point's parameters are those its edge point projects to. An edge
/// that lies on a surface only within tolerance (a sample of an intersection curve whose
/// projection did not converge, an imported edge) then puts the point it shares with the next
/// face in a different place on each face, and the shell's mesh cracks there. Each vertex at a
/// point of the boundary loops (the same parameters, as the triangulation got them) that is
/// farther than `TOLERANCE` from the nearest point of the face's edges goes to that point, when
/// it is within `tol`, so the faces of an edge have the very same points along it. Within
/// `TOLERANCE` the points are already the same, and a face keeps them on its surface: grid
/// points can lie on a boundary between its points (the ruling lines of a plane), and moving
/// only the boundary points would bend the boundary there.
fn onto_edges(mesh: &mut PolygonMesh, boundary: &PolyBoundary, points: &[Point3], tol: f64) {
let key = |x: f64, y: f64| [spade_round(x).to_bits(), spade_round(y).to_bits()];
let loops: rustc_hash::FxHashSet<[u64; 2]> = boundary.0.iter().flatten().map(|p| key(p.x, p.y)).collect();
let on_loops: Vec<bool> = mesh.uv_coords().iter().map(|uv| loops.contains(&key(uv.x, uv.y))).collect();
let cell = |p: Point3| [p.x, p.y, p.z].map(|x| (x / tol).floor() as i64);
let mut grid: HashMap<[i64; 3], Vec<Point3>> = HashMap::default();
points.iter().for_each(|p| grid.entry(cell(*p)).or_default().push(*p));
for (p, _) in mesh.positions_mut().iter_mut().zip(on_loops).filter(|(_, on)| *on) {
let c = cell(*p);
let mut best = (tol * tol, None);
for d in (0..27).map(|k| [k % 3 - 1, k / 3 % 3 - 1, k / 9 - 1]) {
let near = [c[0].saturating_add(d[0]), c[1].saturating_add(d[1]), c[2].saturating_add(d[2])];
for q in grid.get(&near).into_iter().flatten() {
let d2 = q.distance2(*p);
if d2 <= best.0 {
best = (d2, Some(*q));
}
}
}
if let Some(q) = best.1.filter(|_| best.0 > TOLERANCE2) {
*p = q;
}
}
}
#[derive(Debug, Default, Clone)]
struct PolyBoundaryPiece(Vec<Point2>);