UI: rim and wall picking tests for a circle of arcs that is one wall

A circle drawn as two half arcs now extrudes to one full-turn wall with its
seam edge, and each rim is one closed edge (tangency-labs/smlib#67). The tests
keep their intent on that body: a rim clicked once is one item, measured and
highlighted whole and picked from either side; a wall picked either side of
its seam is the same surface, lit whole, and toggles off from the other side;
every face is its own patch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-30 16:03:31 -04:00
committed by jyviko
parent 8ed4663ae8
commit 6d8e002f86
2 changed files with 81 additions and 66 deletions
+9 -9
View File
@@ -2292,9 +2292,8 @@ fn the_picks_go_with_the_next_message_once() {
assert_eq!(script.calls().pop().as_deref(), Some("prompt: and now?"));
}
/// Two clicks on two rims are two things, not four edges (a click on a rim selects both halves for
/// the commands; the chat counts and describes what was clicked). The cylinder is a circle drawn
/// as two half arcs, extruded, so each rim is in two halves.
/// Two clicks on two rims are two things (the chat counts and describes what was clicked). The
/// cylinder is a circle drawn as two half arcs, extruded: one wall, each rim one closed edge.
#[test]
fn a_rim_clicked_once_is_one_item_not_its_pieces() {
use solidcraft_engine::Sel;
@@ -2314,21 +2313,22 @@ fn a_rim_clicked_once_is_one_item_not_its_pieces() {
};
crate::viewport::select(&mut app, Some(Sel::Edge { body: name.clone(), index: top, point: mid_top }), false, false);
crate::viewport::select(&mut app, Some(Sel::Edge { body: name, index: bottom, point: mid_bottom }), true, false);
assert_eq!(app.session.selection.len(), 4, "two clicks selected four halves");
assert_eq!(app.session.selection.len(), 2, "two clicks selected the two rims");
assert_eq!(selected_count(&mut app), 2, "the checkbox counts the two clicks");
app.agent_chat.attach_selection = true;
send(&mut app, &ctx, "these two rims".into());
let prompt = script.calls().pop().expect("a prompt");
assert!(prompt.contains("2 item(s)"), "{prompt}");
assert_eq!(prompt.matches("(2 pieces)").count(), 2, "{prompt}");
// A rim of radius 10 is about 62.8 mm round (the kernel's own figure), both halves together.
assert!(prompt.contains("Edge 62.") && prompt.contains("mm (2 pieces)"), "{prompt}");
assert!(!prompt.contains("pieces)"), "{prompt}");
// A rim of radius 10 is about 62.8 mm round (the kernel's own figure), the whole circle.
assert_eq!(prompt.matches("Edge 62.").count(), 2, "{prompt}");
let Some(Item::User { context, .. }) = app.agent_chat.items.last() else { panic!("a sent message") };
assert_eq!(context.items.len(), 2);
assert_eq!(context.total(), 2);
// The detail still names the piece indices the tools take.
assert!(context.items.iter().all(|i| i.detail.contains("indices [")), "{:?}", context.items);
// The detail names the index the tools take.
let indices = [top, bottom].map(|i| format!("index {i}, "));
assert!(context.items.iter().zip(&indices).all(|(item, index)| item.detail.contains(index)), "{:?}", context.items);
}
/// The same for a surface the kernel splits at a seam: one click on the cylinder's wall is one item.
+72 -57
View File
@@ -8304,10 +8304,10 @@ mod tests {
app
}
/// A Ø20 × 20 cylinder whose wall and rims are each in two halves: a circle drawn as two
/// half arcs, extruded (one wall face per arc). SMLib's own cylinder has one wall and one
/// edge per rim; a rim or wall in pieces comes from a profile like this one or an import.
fn split_cylinder_app() -> SolveApp {
/// A Ø20 × 20 cylinder from a circle drawn as two half arcs, extruded: the arcs on one
/// circle sweep one full-turn wall with its seam edge, and each rim is one closed edge, as
/// SMLib's own cylinder has (a rim or wall in pieces now comes only from an import).
fn arcs_cylinder_app() -> SolveApp {
let mut s = solidcraft_engine::Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s.execute("sketch.arc.three_point", &json!({"start": [10, 0], "end": [-10, 0], "through": [0, 10]})).unwrap();
@@ -8679,7 +8679,7 @@ mod tests {
#[test]
fn circular_items_measure_as_whole_rims_and_surfaces() {
let mut app = split_cylinder_app();
let mut app = arcs_cylinder_app();
let st = app.session.world_state().unwrap();
let b = &st.bodies[0];
let m = b.mesh();
@@ -8698,16 +8698,18 @@ mod tests {
assert_eq!(d.items().len(), 1);
let faces = m.edge_faces.iter().zip(&m.seams).find(|(_, seam)| **seam).unwrap().0;
assert_eq!(faces.len(), 1, "the seam is inside the one wall");
let wall = Sel::Face { body: b.name.clone(), index: faces[0] as usize, point: Vec3::new(10.0, 0.0, 10.0) };
let wall_pieces = grouped_selection(&app.session, &wall);
assert_eq!(wall_pieces, std::slice::from_ref(&wall));
let result = app.session.measure_items(&wall_pieces);
assert_eq!(result["items"].as_array().unwrap().len(), 1);
assert!((result["items"][0]["area_mm2"].as_f64().unwrap() - 400.0 * std::f64::consts::PI).abs() < 10.0, "{result}");
let mut d = crate::dialogs::Dialog::for_command(&app, "inspect.measure").unwrap();
d.pick(&app.session, wall.clone());
assert_eq!(d.items().len(), 2, "a rim and a wall are two measurement targets");
d.pick(&app.session, Sel::Face { body: b.name.clone(), index: faces[1] as usize, point: Vec3::new(-10.0, 0.0, 10.0) });
assert_eq!(d.items().len(), 1, "the other half toggles the same wall");
d.pick(&app.session, Sel::Face { body: b.name.clone(), index: faces[0] as usize, point: Vec3::new(-10.0, 0.0, 10.0) });
assert_eq!(d.items().len(), 1, "a pick across the seam toggles the same wall");
}
#[test]
@@ -8759,87 +8761,100 @@ mod tests {
}
#[test]
fn circular_rim_hover_click_and_dialog_pick_include_both_halves() {
let mut app = split_cylinder_app();
fn circular_rim_hover_click_and_dialog_pick_take_the_whole_rim() {
let mut app = arcs_cylinder_app();
let st = app.session.world_state().unwrap();
let b = &st.bodies[0];
let m = b.mesh();
let top = m.edges.iter().position(|e| e.len() > 3 && e.iter().all(|p| (p.z - 20.0).abs() < 1e-6)).unwrap();
let rim = m.circular_rim(top);
assert_eq!(rim.len(), 2);
let line_count: usize = rim.iter().map(|&i| m.edges[i].len() - 1).sum();
for &i in &rim {
let mid = polyline_mid(&m.edges[i]);
app.viewport.hover = Some(Hit::Edge { body: b.name.clone(), index: i, mid });
assert_eq!(build_highlight(&app).merged().lines.len(), 2 * line_count * crate::gpu::LINE_SIZE);
let item = hit_sel(app.viewport.hover.as_ref().unwrap()).unwrap();
select(&mut app, Some(item.clone()), false, false);
assert_eq!(app.session.selection.len(), 2);
// Selecting both pieces must not draw each highlight twice.
assert_eq!(build_highlight(&app).merged().lines.len(), 2 * line_count * crate::gpu::LINE_SIZE);
// Ctrl/Shift clicking either half deselects the entire rim.
select(&mut app, Some(item.clone()), true, true);
assert!(app.session.selection.is_empty());
let mut d = crate::dialogs::Dialog::for_command(&app, "solid.chamfer").unwrap();
if let crate::dialogs::Kind::Fillet { chain, .. } = &mut d.kind {
*chain = false;
}
d.pick(&app.session, item.clone());
assert_eq!(d.items().len(), 2);
d.pick(&app.session, item);
assert!(d.items().is_empty());
// The rim is one closed edge round the whole circle.
assert_eq!(m.circular_rim(top), vec![top]);
let rim = &m.edges[top];
assert!(rim.first().zip(rim.last()).is_some_and(|(a, b)| a.dist(*b) < 1e-9));
assert!(rim.iter().all(|p| (p.x.hypot(p.y) - 10.0).abs() < 1e-6));
let line_count = rim.len() - 1;
let mid = polyline_mid(rim);
app.viewport.hover = Some(Hit::Edge { body: b.name.clone(), index: top, mid });
assert_eq!(build_highlight(&app).merged().lines.len(), 2 * line_count * crate::gpu::LINE_SIZE);
let item = hit_sel(app.viewport.hover.as_ref().unwrap()).unwrap();
select(&mut app, Some(item.clone()), false, false);
assert_eq!(app.session.selection, std::slice::from_ref(&item));
assert_eq!(build_highlight(&app).merged().lines.len(), 2 * line_count * crate::gpu::LINE_SIZE);
// Ctrl/Shift clicking the rim deselects it.
select(&mut app, Some(item.clone()), true, true);
assert!(app.session.selection.is_empty());
let mut d = crate::dialogs::Dialog::for_command(&app, "solid.chamfer").unwrap();
if let crate::dialogs::Kind::Fillet { chain, .. } = &mut d.kind {
*chain = false;
}
d.pick(&app.session, item.clone());
assert_eq!(d.items().len(), 1);
d.pick(&app.session, item);
assert!(d.items().is_empty());
// Screen-space picking works from either side of the rim as the model turns.
for &i in &rim {
let mid = polyline_mid(&m.edges[i]);
app.cam = app.cam.looking_from(Vec3::new(mid.x, mid.y, 20.0));
for at in [rim[rim.len() / 4], rim[3 * rim.len() / 4]] {
app.cam = app.cam.looking_from(Vec3::new(at.x, at.y, 20.0));
app.cam.target = Vec3::new(0.0, 0.0, 10.0);
for perspective in [false, true] {
app.ui.perspective = perspective;
let proj = projection(&app, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)));
let hits = pick(&app, &proj, proj.to_screen(mid).unwrap());
let hits = pick(&app, &proj, proj.to_screen(at).unwrap());
let (_, sel) = candidate(&app, &hits).unwrap();
assert!(matches!(sel, Sel::Edge { .. }), "{hits:?}");
assert_eq!(grouped_selection(&app.session, &sel).len(), 2);
assert!(matches!(sel, Sel::Edge { index, .. } if index == top), "{hits:?}");
assert_eq!(grouped_selection(&app.session, &sel).len(), 1);
}
}
}
#[test]
fn curved_face_hover_selection_and_dialogs_cross_the_seam() {
let mut app = split_cylinder_app();
let mut app = arcs_cylinder_app();
let st = app.session.world_state().unwrap();
let b = &st.bodies[0];
let m = b.mesh();
let faces = m.edge_faces.iter().zip(&m.seams).find(|(_, seam)| **seam).unwrap().0;
assert_eq!(faces.len(), 2);
let triangle_count = m.tri_face.iter().filter(|f| faces.contains(f)).count();
for &f in faces {
let index = f as usize;
let t = m.triangles.iter().zip(&m.tri_face).find(|(_, tf)| **tf == f).unwrap().0;
let [a, c, d] = m.tri(t).unwrap();
let point = (a + c + d) / 3.0;
let seam = m.seams.iter().position(|seam| *seam).unwrap();
let faces = &m.edge_faces[seam];
assert_eq!(faces.len(), 1, "the seam is inside the one wall");
let wall = faces[0];
let index = wall as usize;
assert_eq!(m.surface_patch(index), vec![index]);
let triangle_count = m.tri_face.iter().filter(|f| **f == wall).count();
// The wall's triangles nearest the seam on either side of it, by their centres' turn
// from the seam round the axis.
let at = m.edges[seam][0];
let turn = |p: Vec3| (at.x * p.y - at.y * p.x).atan2(at.x * p.x + at.y * p.y);
let centres: Vec<Vec3> = m
.triangles
.iter()
.zip(&m.tri_face)
.filter(|(_, f)| **f == wall)
.filter_map(|(t, _)| m.tri(t).map(|[a, c, d]| (a + c + d) / 3.0))
.collect();
let side =
|sign: f64| centres.iter().copied().filter(|p| turn(*p) * sign > 0.0).min_by(|p, q| turn(*p).abs().total_cmp(&turn(*q).abs())).unwrap();
let sides = [side(1.0), side(-1.0)];
assert!(sides.iter().all(|p| turn(*p).abs() < 0.5), "{sides:?}");
for (k, &point) in sides.iter().enumerate() {
app.viewport.hover = Some(Hit::Face { body: b.name.clone(), index, point });
assert_eq!(build_highlight(&app).merged().tris.len(), triangle_count * 3 * crate::gpu::TRI_SIZE);
let item = hit_sel(app.viewport.hover.as_ref().unwrap()).unwrap();
select(&mut app, Some(item.clone()), false, false);
assert_eq!(app.session.selection.len(), 2);
assert_eq!(app.session.selection, std::slice::from_ref(&item), "keep the original tool placement point");
assert_eq!(build_highlight(&app).merged().tris.len(), triangle_count * 3 * crate::gpu::TRI_SIZE);
assert_eq!(app.session.selection.first(), Some(&item), "keep the original tool placement point first");
let mut dialog = crate::dialogs::Dialog::for_command(&app, "solid.chamfer").unwrap();
assert_eq!(dialog.items().len(), 2);
assert_eq!(dialog.items().len(), 1);
dialog.pick(&app.session, item.clone());
assert!(dialog.items().is_empty());
dialog.pick(&app.session, item);
assert_eq!(dialog.items().len(), 2);
// A point elsewhere on the opposite half still toggles the whole surface off.
let other = faces.iter().find(|&&x| x != f).unwrap();
select(&mut app, Some(Sel::Face { body: b.name.clone(), index: *other as usize, point: m.positions[0] }), true, true);
assert_eq!(dialog.items().len(), 1);
// A point on the other side of the seam still toggles the whole surface off.
select(&mut app, Some(Sel::Face { body: b.name.clone(), index, point: sides[1 - k] }), true, true);
assert!(app.session.selection.is_empty());
}
// Planar caps and box corners stay separate.
let cap = (0..b.body.face_count()).find(|&i| m.surface_patch(i).len() == 1).unwrap();
assert_eq!(m.surface_patch(cap), vec![cap]);
// Every face is its own patch: the planar caps, the wall, a box's faces.
for i in 0..b.body.face_count() {
assert_eq!(m.surface_patch(i), vec![i]);
}
app.session.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
let st = app.session.world_state().unwrap();
let box_body = st.bodies.last().unwrap();