Display meshes: tests count the planner's grids; a mesh's outline is one line per straight edge

The display mesh is the tessellation planner's (8b6bfc48c: the mesher plans a schema 4
record), which grids a planar face with no side longer than a fifth of the body's extent
(docs/capabilities.md, "Display meshes"); truck's mesher gave a box face two triangles. The
tests that counted truck's mesh now count the planner's, worked out by hand from that rule:
- a 10 x 20 x 30 box (37.4 mm diagonal): edges in 2, 3 and 5 pieces, faces of 2 x 3, 2 x 5
  and 3 x 5 cells, 124 triangles; welded, the box lattice's 3 x 4 x 6 nodes less the
  1 x 2 x 4 inside it, 64 positions (obj_export_is_welded_and_keeps_names);
- a 20 x 15 x 10 box: 4 x 3, 4 x 2 and 3 x 2 cells, 104 triangles (an_opacity_class_change...);
- a 20 x 20 x 10 box saved as a mesh: its edges in 4 and 2 pieces, 40 segments per box
  (imported_mesh_edges_draw_in_each_style...);
- the cylinder's top cap is under a third of its triangles (select_other_over_a_dense_cluster...).

An imported mesh's outline was one line per crease side, so a box exported at that spacing
showed each edge in pieces: a click on a 10 mm edge measured 5 mm, or hit the node between
two pieces (measure_imported_stl_edges_and_vertices..., mesh_outline_hover_cache...). Outline
sides that run straight on through a vertex no other outline side meets are now one line, as
the CAD model's edge was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-28 22:28:10 -04:00
committed by Irakli Kourtis
parent 983b1ca8f0
commit 426a505a87
5 changed files with 73 additions and 11 deletions
+55 -4
View File
@@ -156,11 +156,55 @@ fn mesh_outlines(positions: &[Vec3], triangles: &[[u32; 3]]) -> Vec<Vec<Vec3>> {
[(_, _, i), (_, _, j)] => normal(*i as usize).zip(normal(*j as usize)).is_some_and(|(n, m)| n.dot(m) <= cos),
_ => true,
};
if visible && let (Some(&a), Some(&b)) = (positions.get(a as usize), positions.get(b as usize)) {
edges.push(vec![a, b]);
if visible && positions.get(a as usize).is_some() && positions.get(b as usize).is_some() {
edges.push((a, b));
}
}
edges
straight_runs(positions, &edges)
}
/// The outline sides `sides` (vertex index pairs) as polylines: sides that run on straight
/// through a vertex no other outline side meets are one line. A mesh whose straight edges are
/// in pieces (a box exported at a grid's spacing) then has one outline per edge, as its CAD
/// model did, and a click on it measures the whole edge.
fn straight_runs(positions: &[Vec3], sides: &[(u32, u32)]) -> Vec<Vec<Vec3>> {
let mut at: std::collections::BTreeMap<u32, Vec<usize>> = std::collections::BTreeMap::new();
for (i, &(a, b)) in sides.iter().enumerate() {
at.entry(a).or_default().push(i);
at.entry(b).or_default().push(i);
}
let point = |v: u32| positions.get(v as usize).copied().unwrap_or(Vec3::ZERO);
let other = |i: usize, v: u32| sides.get(i).map_or(v, |&(a, b)| if a == v { b } else { a });
// The side that continues `i` straight on through `v`, when `v` joins exactly two.
let next = |i: usize, v: u32| -> Option<usize> {
let [p, q] = at.get(&v)?.as_slice() else { return None };
let j = if *p == i { *q } else { *p };
let (into, out) = ((point(v) - point(other(i, v))).normalized()?, (point(other(j, v)) - point(v)).normalized()?);
(into.dot(out) > 1.0 - 1e-9).then_some(j)
};
let mut used = vec![false; sides.len()];
// Marks side `i` used; false when it already was.
let mut take = |i: usize| used.get_mut(i).is_some_and(|u| !std::mem::replace(u, true));
let mut runs = Vec::new();
for (i, &(a, b)) in sides.iter().enumerate() {
if !take(i) {
continue;
}
let mut run = std::collections::VecDeque::from([a, b]);
for (mut side, mut end, front) in [(i, b, false), (i, a, true)] {
while let Some(j) = next(side, end).filter(|j| take(*j)) {
end = other(j, end);
side = j;
if front {
run.push_front(end);
} else {
run.push_back(end);
}
}
}
runs.push(run.into_iter().map(point).collect());
}
runs
}
/// Only proper rotations and translations preserve triangle winding, normal lengths and the
@@ -182,7 +226,8 @@ mod tests {
#[test]
fn mesh_outlines_keep_boundaries_and_creases_without_smooth_facet_lines() {
for (degrees, expected) in [(0.0f64, 4), (15.0, 4), (30.0, 5), (45.0, 5), (90.0, 5)] {
// Flat (0 degrees), the sides from Y through the origin to -Y run straight on: one line.
for (degrees, expected) in [(0.0f64, 3), (15.0, 4), (30.0, 5), (45.0, 5), (90.0, 5)] {
let angle = degrees.to_radians();
let p = [Vec3::ZERO, Vec3::X, Vec3::Y, Vec3::new(0., -angle.cos(), angle.sin())];
let triangles = [[0, 1, 2], [1, 0, 3]];
@@ -215,6 +260,12 @@ mod tests {
let edges = mesh_outlines(&p, &triangles);
assert_eq!(edges.len(), 2 * n as usize);
assert!(edges.iter().all(|e| e[0].z == e[1].z));
// A straight edge in pieces (a node at its middle, where the facets of the face meet)
// is one outline; its corners stay corners.
let p = [Vec3::ZERO, Vec3::X * 2., Vec3::new(2., 1., 0.), Vec3::Y, Vec3::X];
let edges = mesh_outlines(&p, &[[0, 4, 3], [4, 1, 2], [4, 2, 3]]);
assert_eq!(edges.len(), 4, "{edges:?}");
assert!(edges.iter().any(|e| e == &[Vec3::ZERO, Vec3::X, Vec3::X * 2.] || e == &[Vec3::X * 2., Vec3::X, Vec3::ZERO]), "{edges:?}");
// Bad indices and zero-area facets do not invent outlines or panic.
assert!(mesh_outlines(&[Vec3::ZERO, Vec3::X, Vec3::X * 2.], &[[0, 1, 99], [0, 1, 2]]).is_empty());
}
+8 -4
View File
@@ -838,8 +838,12 @@ mod tests {
assert!(o.contains("o Body1") && o.contains("\nf "));
}
/// OBJ shares a face's vertices (a cube: 8 positions, 6 normals, not 36 of each) and keeps
/// the body name as written, spaces included.
/// OBJ shares the vertices faces meet at and keeps the body name as written, spaces included.
/// The 10 × 20 × 30 box meshes as the planner's grids, no side longer than a fifth of its
/// extent (the 37.4 mm diagonal): its edges in 2, 3 and 5 pieces, so its faces are grids of
/// 2 × 3, 2 × 5 and 3 × 5 cells, 2 · (6 + 10 + 15) · 2 = 124 triangles. Welded, their
/// corners are the 3 · 4 · 6 = 72 nodes of the box's lattice less the 1 · 2 · 4 inside it: 64
/// positions (unwelded, 372), and one normal per face.
#[test]
fn obj_export_is_welded_and_keeps_names() {
let (_, m) = model();
@@ -850,10 +854,10 @@ mod tests {
let o = String::from_utf8(export(&st, &[], Format::Obj, "t").unwrap()).unwrap();
let count = |p: &str| o.lines().filter(|l| l.starts_with(p)).count();
assert!(o.contains("\no Red Block\n"), "{o}");
assert_eq!((count("v "), count("vn "), count("f ")), (8, 6, 12), "{o}");
assert_eq!((count("v "), count("vn "), count("f ")), (64, 6, 124), "{o}");
let ids: Vec<usize> =
o.lines().filter(|l| l.starts_with("f ")).flat_map(|l| l.split([' ', '/']).skip(1)).filter_map(|x| x.parse().ok()).collect();
assert!(ids.iter().all(|i| (1..=8).contains(i) || (1..=6).contains(i)));
assert!(ids.iter().all(|i| (1..=64).contains(i)));
}
/// The design properties land in each format's own metadata (#1015): STEP and IGES headers,
@@ -796,7 +796,9 @@ fn select_other_over_a_dense_cluster_looks_the_face_up_once() {
// not the face's or the body's; every point on the face is still counted.
let mut c = cylinder_with_cluster(json!({"plane": {"face": [0, 0, 20]}}), [5.0, 4.0], "top");
let (many, body, face, near) = (c.many, c.body_tris, c.face_tris, c.near_tris);
assert!(body >= 4 * face && 4 * near <= face, "{body} triangles in the body, {face} in the face, {near} of them near the points");
// The top cap is one of the cylinder's three faces (the planner meshes both caps alike), under
// a third of its triangles; the points lie on a small part of it.
assert!(body >= 3 * face && 4 * near <= face, "{body} triangles in the body, {face} in the face, {near} of them near the points");
let (list, (lookups, tested)) = c.select_other();
assert_eq!((rows_named(&list, "Point"), rows_named(&list, "Face")), (crate::select_other::MAX_PER_KIND, 1), "{list}");
assert_eq!(list["total"], json!(many + 1), "every point on the face is counted: {list}");
+4 -1
View File
@@ -6999,7 +6999,10 @@ mod tests {
for style in 0..4 {
app.ui.visual_style = style;
let scene = build_scene(&app).merged();
let lines = if style == 1 { 0 } else { 24 * crate::gpu::LINE_SIZE };
// Twelve edges per placed box, in the saved mesh's pieces: no side longer than a fifth
// of the 30 mm diagonal, so the eight 20 mm edges in four and the four 10 mm ones in
// two, 40 segments.
let lines = if style == 1 { 0 } else { 2 * 40 * crate::gpu::LINE_SIZE };
assert_eq!(scene.lines.len(), lines, "style {style}: twelve edges per placed box");
assert_eq!(scene.hidden.len(), if style == 3 { lines } else { 0 });
assert_eq!(scene.tris.is_empty(), style == 2, "wireframe draws only the edges");
@@ -219,7 +219,9 @@ fn an_opacity_class_change_keeps_vertices_and_rewrites_only_the_runs() {
assert!(block.bytes.opaque.is_empty() && !block.bytes.glass.is_empty(), "the triangles moved to the glass runs");
let triangles: u32 = block.bytes.glass.iter().map(|r| r.len() as u32).sum::<u32>() / 3;
assert_eq!(block.bytes.glass.len(), 1, "one body of one class is one run, drawn with no index buffer");
assert_eq!(triangles, 12, "every triangle of the box");
// The planner's grids, no side longer than a fifth of the box's 26.9 mm diagonal: edges in
// 4, 3 and 2 pieces, faces of 4 × 3, 4 × 2 and 3 × 2 cells, 2 · (12 + 8 + 6) · 2 triangles.
assert_eq!(triangles, 104, "every triangle of the box");
let vertex = block.bytes.glass.first().map_or(0, |r| r.start) as usize;
let at = vertex * crate::gpu::TRI_SIZE;
let colour = first_vertex(&second, block.bytes.vertices.get(at..).unwrap_or_default()).1;