diff --git a/crates/engine/src/cmd/measure_sel.rs b/crates/engine/src/cmd/measure_sel.rs index 95dc2dc9..9c978dfc 100644 --- a/crates/engine/src/cmd/measure_sel.rs +++ b/crates/engine/src/cmd/measure_sel.rs @@ -55,13 +55,13 @@ fn densify(pts: &[Vec3]) -> Vec { /// The mesh edge a selection means: by index if it still passes through the point, otherwise /// the nearest. -fn edge_points(m: &Mesh, index: usize, point: Vec3) -> Option> { +fn edge_index(m: &Mesh, index: usize, point: Vec3) -> Option { let near = |e: &Vec| e.windows(2).map(|w| point.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min); let tol = m.bounds().diagonal().max(1.0) * 1e-6; - if let Some(e) = m.edges.get(index).filter(|e| near(e) < tol) { - return Some(e.clone()); + if m.edges.get(index).is_some_and(|e| near(e) < tol) { + return Some(index); } - m.edges.iter().min_by(|a, b| near(a).total_cmp(&near(b))).cloned() + m.edges.iter().enumerate().min_by(|(_, a), (_, b)| near(a).total_cmp(&near(b))).map(|(i, _)| i) } fn face_tris(m: &Mesh, f: usize) -> Vec<[Vec3; 3]> { @@ -75,15 +75,23 @@ fn describe(s: &Session, sel: &Sel) -> Option<(Geo, Option, Value)> { Sel::Vertex { point, .. } => (Geo::Points(vec![*point]), None, json!({"type": "vertex", "position": point})), Sel::Edge { body, index, point } => { let m = st.body(body)?.mesh(); - let pts = edge_points(&m, *index, *point)?; + let index = edge_index(&m, *index, *point)?; + let pts = m.edges.get(index)?; + let rim = m.circular_rim(index); + if rim.len() > 1 { + let pieces: Vec<&Vec> = rim.iter().filter_map(|&i| m.edges.get(i)).collect(); + let len: f64 = pieces.iter().map(|p| polyline_len(p)).sum(); + let points = pieces.iter().flat_map(|p| densify(p)).collect(); + return Some((Geo::Points(points), None, json!({"type": "edge", "length_mm": len}))); + } let (a, b) = (*pts.first()?, *pts.last()?); let straight = (b - a).normalized().filter(|d| pts.iter().all(|p| (*p - a).cross(*d).len() < 1e-6 * (1.0 + a.dist(b)))); - let len = polyline_len(&pts); - (Geo::Points(densify(&pts)), straight, json!({"type": "edge", "length_mm": len})) + let len = polyline_len(pts); + (Geo::Points(densify(pts)), straight, json!({"type": "edge", "length_mm": len})) } Sel::Face { body, index, .. } => { let m = st.body(body)?.mesh(); - let tris = face_tris(&m, *index); + let tris = m.surface_patch(*index).into_iter().flat_map(|f| face_tris(&m, f)).collect::>(); let area: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum(); let normals: Vec = tris.iter().filter_map(|[a, b, c]| (*b - *a).cross(*c - *a).normalized()).collect(); let planar = normals.first().copied().filter(|n0| normals.iter().all(|n| n.dot(*n0) > 1.0 - 1e-6)); @@ -150,10 +158,41 @@ fn closest(a: &Geo, b: &Geo) -> Option<(Vec3, Vec3)> { pub(crate) fn measure_items(s: &Session, items: &Value) -> Result { let cmd = "inspect.measure"; let list = items.as_array().ok_or_else(|| bad(cmd, "`items` must be a list of selections"))?; - if list.is_empty() || list.len() > 2 { + if list.is_empty() || list.len() > 1000 { return Err(bad(cmd, "measure one or two items")); } - let sels: Vec = list.iter().map(|v| serde_json::from_value::(v.clone()).map_err(|e| bad(cmd, e.to_string()))).collect::>()?; + let st = s.world_state(); + let mut sels = Vec::new(); + for value in list { + let mut sel = serde_json::from_value::(value.clone()).map_err(|e| bad(cmd, e.to_string()))?; + // Two kernel halves of one visible item are one measurement target. + match &mut sel { + Sel::Edge { body, index, point } => { + if let Some(b) = st.body(body) { + let m = b.mesh(); + let resolved = edge_index(&m, *index, *point).unwrap_or(*index); + *index = m.circular_rim(resolved).first().copied().unwrap_or(resolved); + } + } + Sel::Face { body, index, .. } => { + if let Some(b) = st.body(body) { + *index = b.mesh().surface_patch(*index).first().copied().unwrap_or(*index); + } + } + _ => {} + } + let duplicate = sels.iter().any(|x| match (x, &sel) { + (Sel::Edge { body: a, index: i, .. }, Sel::Edge { body: b, index: j, .. }) + | (Sel::Face { body: a, index: i, .. }, Sel::Face { body: b, index: j, .. }) => a == b && i == j, + _ => false, + }); + if !duplicate { + sels.push(sel); + } + if sels.len() > 2 { + return Err(bad(cmd, "measure one or two items")); + } + } let mut described = Vec::new(); for x in &sels { described.push(describe(s, x).ok_or_else(|| bad(cmd, "an item can't be measured (pick vertices, edges, faces or bodies)"))?); diff --git a/crates/geom/src/mesh.rs b/crates/geom/src/mesh.rs index 32c3373a..296f4808 100644 --- a/crates/geom/src/mesh.rs +++ b/crates/geom/src/mesh.rs @@ -82,6 +82,71 @@ impl Mesh { pub fn face_edges(&self, f: u32) -> Vec { self.edge_faces.iter().enumerate().filter(|(_, fs)| fs.contains(&f)).map(|(i, _)| i).collect() } + /// Pieces of one surface joined across hidden seams, including `face` itself. + pub fn surface_patch(&self, face: usize) -> Vec { + let mut patch = vec![face]; + let mut todo = vec![face]; + while let Some(f) = todo.pop() { + for (ei, fs) in self.edge_faces.iter().enumerate() { + if !self.seams.get(ei).copied().unwrap_or(false) || !fs.iter().any(|x| *x as usize == f) { + continue; + } + for &next in fs { + let next = next as usize; + if !patch.contains(&next) { + patch.push(next); + todo.push(next); + } + } + } + } + patch.sort_unstable(); + patch + } + + /// A complete circular rim may be split into several kernel edges. Keep its pieces + /// together for picking, without joining other tangent curves or changing edge indices. + pub fn circular_rim(&self, edge: usize) -> Vec { + let single = vec![edge]; + let Some(p) = self.edges.get(edge).filter(|p| p.len() >= 3) else { return single }; + let (Some(&a), Some(&b), Some(&c)) = (p.first(), p.get(p.len() / 2), p.last()) else { return single }; + let (u, v) = (b - a, c - a); + let n = u.cross(v); + if !(n.len2() > u.len2() * v.len2() * 1e-12) { + return single; + } + let center = a + (v.cross(n) * u.len2() + n.cross(u) * v.len2()) / (2.0 * n.len2()); + let radius = center.dist(a); + let normal = n.normalized().unwrap_or(Vec3::ZERO); + let tol = (radius * 1e-6).max(1e-9); + let mut rim = self.tangent_chain(edge, 2f64.to_radians()); + if rim.len() < 2 || !radius.is_finite() { + return single; + } + // All samples must lie on this circle, and every end must join exactly one other + // piece. Open arcs, ellipses, and branching tangent chains stay separate. + for &i in &rim { + let Some(points) = self.edges.get(i) else { return single }; + if points.iter().any(|q| !q.is_finite() || (q.dist(center) - radius).abs() > tol || (*q - center).dot(normal).abs() > tol) { + return single; + } + for end in [points.first(), points.last()].into_iter().flatten() { + let joins = rim + .iter() + .filter(|&&j| j != i) + .filter_map(|&j| self.edges.get(j)) + .flat_map(|q| [q.first(), q.last()]) + .flatten() + .filter(|q| q.dist(*end) < tol) + .count(); + if joins != 1 { + return single; + } + } + } + rim.sort_unstable(); + rim + } /// The edges reached from `e` through vertices where the next edge continues smoothly /// (angle between the end tangents below `max_angle`), including `e` itself. pub fn tangent_chain(&self, e: usize, max_angle: f64) -> Vec { diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index af1b6f68..3e996d14 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -84,6 +84,46 @@ fn tangent_chains_follow_smooth_edges() { assert_eq!(m.face_edges(1), vec![1, 2]); } +#[test] +fn circular_rims_group_only_closed_cocircular_edges() { + let arc = |start: f64, sweep: f64, yscale: f64| -> Vec { + (0..=32) + .map(|i| { + let a = start + sweep * i as f64 / 32.0; + Vec3::new(10.0 * a.cos(), yscale * 10.0 * a.sin(), 5.0) + }) + .collect() + }; + let pi = std::f64::consts::PI; + let mut m = Mesh { + positions: vec![Vec3::new(-10.0, -10.0, 5.0), Vec3::new(10.0, 10.0, 5.0)], + edges: vec![arc(0.0, pi, 1.0), arc(2.0 * pi, -pi, 1.0)], + ..Default::default() + }; + assert_eq!(m.circular_rim(0), vec![0, 1]); + assert_eq!(m.circular_rim(1), vec![0, 1]); + // A hidden seam cannot form a selectable rim. + m.seams = vec![false, true]; + assert_eq!(m.circular_rim(0), vec![0]); + m.seams.clear(); + m.edges[1] = arc(pi, pi / 2.0, 1.0); + assert_eq!(m.circular_rim(0), vec![0]); + // A closed, smooth ellipse is not a circle. + m.edges = vec![arc(0.0, pi, 0.7), arc(pi, pi, 0.7)]; + assert_eq!(m.circular_rim(0), vec![0]); + assert_eq!(m.circular_rim(99), vec![99]); +} + +#[test] +fn surface_patches_cross_only_hidden_seams() { + let mut m = Mesh { edge_faces: vec![vec![0, 1], vec![1, 2], vec![2, 3]], seams: vec![true, true, false], ..Default::default() }; + assert_eq!(m.surface_patch(0), vec![0, 1, 2]); + assert_eq!(m.surface_patch(2), vec![0, 1, 2]); + assert_eq!(m.surface_patch(3), vec![3]); + m.seams.clear(); + assert_eq!(m.surface_patch(0), vec![0]); +} + #[test] fn cubic_and_conic_segments_measure_exactly() { use crate::{Loop2, Seg2, Vec2}; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1382537..7f3f83ef 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -480,10 +480,12 @@ impl Dialog { .collect::>() }) .unwrap_or_else(|| vec![sel.clone()]), + (Kind::Measure { .. }, _) => vec![crate::viewport::measurement_selection(s, sel)], + _ if inp.multi => crate::viewport::grouped_selection(s, sel), _ => vec![sel.clone()], }; for x in picked { - if !inp.items.contains(&x) { + if !inp.items.iter().any(|y| selection::same_item(y, &x)) { inp.items.push(x); } } @@ -631,8 +633,11 @@ impl Dialog { /// A pick in the viewport (already accepted by the active input). pub fn pick(&mut self, s: &Session, sel: Sel) { + let measure = matches!(self.kind, Kind::Measure { .. }); + let sel = if measure { crate::viewport::measurement_selection(s, &sel) } else { sel }; let chain = matches!(self.kind, Kind::Fillet { chain: true, .. }); - let mut picked = vec![sel.clone()]; + let mut picked = + if !measure && self.active_input().is_some_and(|i| i.multi) { crate::viewport::grouped_selection(s, &sel) } else { vec![sel.clone()] }; // Tangent chain: an edge brings the edges that continue it smoothly. if chain && let Sel::Edge { body, index, .. } = &sel @@ -667,13 +672,15 @@ impl Dialog { } /// Box selection result: replace (or add to) the active input. - pub fn take_box(&mut self, sels: Vec, add: bool) { + pub fn take_box(&mut self, s: &Session, sels: Vec, add: bool) { + let measure = matches!(self.kind, Kind::Measure { .. }); let Some(inp) = self.inputs.get_mut(self.active) else { return }; if !add { inp.items.clear(); } for x in sels { - if fits(inp.accept, &x) && !inp.items.contains(&x) { + let x = if measure { crate::viewport::measurement_selection(s, &x) } else { x }; + if fits(inp.accept, &x) && !inp.items.iter().any(|y| selection::same_item(y, &x)) { inp.items.push(x); } } diff --git a/crates/ui-egui/src/selection.rs b/crates/ui-egui/src/selection.rs index 413f660d..cfbfe9fe 100644 --- a/crates/ui-egui/src/selection.rs +++ b/crates/ui-egui/src/selection.rs @@ -8,6 +8,15 @@ use solvecraft_engine::geom::Vec3; use crate::viewport::Hit; +/// Model picks retain an on-surface point, but their identity is the body and topology index. +pub(crate) fn same_item(a: &Sel, b: &Sel) -> bool { + match (a, b) { + (Sel::Edge { body: a, index: i, .. }, Sel::Edge { body: b, index: j, .. }) + | (Sel::Face { body: a, index: i, .. }, Sel::Face { body: b, index: j, .. }) => a == b && i == j, + _ => a == b, + } +} + /// What a selection input accepts (bit set). pub type Accept = u16; pub const PROFILES: Accept = 1; @@ -73,11 +82,11 @@ impl SelInput { return; } // A pick of something already in (or a chain fully in) takes it out. - if picked.iter().all(|p| self.items.contains(p)) { - self.items.retain(|x| !picked.contains(x)); + if picked.iter().all(|p| self.items.iter().any(|x| same_item(x, p))) { + self.items.retain(|x| !picked.iter().any(|p| same_item(x, p))); } else { for p in picked { - if !self.items.contains(&p) { + if !self.items.iter().any(|x| same_item(x, &p)) { self.items.push(p); } } diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 45c1d11f..d436dd90 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -1091,7 +1091,14 @@ fn build_highlight(app: &SolveApp) -> GpuScene { let t = Tokens::get(); let mut sc = GpuScene::default(); let st = app.session.world_state(); - let sel = app.highlighted(); + let mut sel = Vec::new(); + for item in app.highlighted() { + for piece in grouped_selection(&app.session, &item) { + if !sel.iter().any(|x| crate::selection::same_item(x, &piece)) { + sel.push(piece); + } + } + } let hover = app.viewport.hover.as_ref(); let size = origin_size(app.cam.half_height()); // Origin planes and construction planes. @@ -1242,9 +1249,12 @@ fn build_highlight(app: &SolveApp) -> GpuScene { if let Some(b) = st.body(body) { let k = t.hover_face_lift; let c = body_rgb(app, body); - face_tris(&mut sc, &b.mesh(), *index, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true); let m = b.mesh(); - for e in m.face_edges(u32::try_from(*index).unwrap_or(u32::MAX)) { + let patch = m.surface_patch(*index); + for &f in &patch { + face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true); + } + for (e, _) in m.edge_faces.iter().enumerate().filter(|(_, fs)| fs.iter().any(|f| patch.contains(&(*f as usize)))) { if let Some(p) = m.edges.get(e) && !m.seams.get(e).copied().unwrap_or(false) { @@ -1254,12 +1264,13 @@ fn build_highlight(app: &SolveApp) -> GpuScene { } } Some(Hit::Edge { body, index, mid }) => { - if let Some(b) = st.body(body) - && let Some(e) = edge_of(&b.mesh(), *index, *mid) - { - let selected = sel.iter().any(|x| matches!(x, Sel::Edge { point, .. } if point.dist(*mid) < 1e-9)); - if !selected { - edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 2.0); + if let Some(b) = st.body(body) { + let m = b.mesh(); + for i in m.circular_rim(*index) { + let selected = sel.iter().any(|x| matches!(x, Sel::Edge { body: b, index: j, .. } if b == body && *j == i)); + if !selected && let Some(e) = if i == *index { edge_of(&m, i, *mid) } else { m.edges.get(i) } { + edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 2.0); + } } } } @@ -1286,7 +1297,7 @@ fn body_rgb(app: &SolveApp, name: &str) -> (u8, u8, u8) { (c.r(), c.g(), c.b()) } -fn polyline_mid(pts: &[Vec3]) -> Vec3 { +pub(crate) fn polyline_mid(pts: &[Vec3]) -> Vec3 { let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum(); let mut acc = 0.0; for w in pts.windows(2) { @@ -1730,12 +1741,13 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { fn select(app: &mut SolveApp, sel: Option, add: bool) { match sel { Some(x) => { - let item = serde_json::to_value(&x).unwrap_or_default(); - if add && app.session.selection.contains(&x) { - let rest: Vec = app.session.selection.iter().filter(|y| **y != x).cloned().collect(); + let items = grouped_selection(&app.session, &x); + if add && items.iter().all(|x| app.session.selection.iter().any(|y| crate::selection::same_item(x, y))) { + let rest: Vec = + app.session.selection.iter().filter(|y| !items.iter().any(|x| crate::selection::same_item(x, y))).cloned().collect(); let _ = app.run("select.set", json!({ "items": rest })); } else { - let _ = app.run("select.set", json!({"items": [item], "add": add})); + let _ = app.run("select.set", json!({"items": items, "add": add})); } } None if !add => { @@ -1745,6 +1757,75 @@ fn select(app: &mut SolveApp, sel: Option, add: bool) { } } +/// Expand a visible rim or surface into the kernel pieces a command needs. The original +/// face pick keeps its point (used by tools such as Hole and construction geometry). +pub(crate) fn grouped_selection(s: &solvecraft_engine::Session, sel: &Sel) -> Vec { + let st = s.world_state(); + match sel { + Sel::Edge { body, index, .. } => { + if let Some(b) = st.body(body) { + let m = b.mesh(); + return m + .circular_rim(*index) + .into_iter() + .filter_map(|i| m.edges.get(i).map(|e| Sel::Edge { body: body.clone(), index: i, point: polyline_mid(e) })) + .collect(); + } + } + Sel::Face { body, index, .. } => { + if let Some(b) = st.body(body) { + let m = b.mesh(); + let mut patch = m.surface_patch(*index); + patch.sort_by_key(|i| usize::from(i != index)); + return patch + .into_iter() + .filter_map(|i| { + if i == *index { + return Some(sel.clone()); + } + m.triangles.iter().zip(&m.tri_face).find_map(|(t, f)| { + if *f as usize != i { + return None; + } + let [a, b, c] = m.tri(t)?; + Some(Sel::Face { body: body.clone(), index: i, point: (a + b + c) / 3.0 }) + }) + }) + .collect(); + } + } + _ => {} + } + vec![sel.clone()] +} + +/// Measure keeps one entry per visible item, rather than using its two slots for kernel halves. +pub(crate) fn measurement_selection(s: &solvecraft_engine::Session, sel: &Sel) -> Sel { + let pieces = grouped_selection(s, sel); + let mut representative = sel.clone(); + match &mut representative { + Sel::Edge { index, .. } => { + if let Some(Sel::Edge { index: i, .. }) = pieces.first() { + *index = *i; + } + } + Sel::Face { index, .. } => { + if let Some(i) = pieces + .iter() + .filter_map(|p| match p { + Sel::Face { index, .. } => Some(*index), + _ => None, + }) + .min() + { + *index = i; + } + } + _ => {} + } + representative +} + /// What a box selects: sketch entities while sketching, otherwise what the open dialog's /// active input takes (edges, faces or bodies), otherwise bodies. fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) { @@ -1794,7 +1875,9 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) { let m = b.mesh(); if accept & EDGES != 0 { for (ei, e) in m.edges.iter().enumerate() { - if !m.seams.get(ei).copied().unwrap_or(false) && bx.takes(&to2(e)) { + let rim = m.circular_rim(ei); + let takes = if bx.crossing { bx.takes(&to2(e)) } else { rim.iter().all(|&i| m.edges.get(i).is_some_and(|p| bx.takes(&to2(p)))) }; + if !m.seams.get(ei).copied().unwrap_or(false) && takes { out.push(Sel::Edge { body: b.name.clone(), index: ei, point: polyline_mid(e) }); } } @@ -1811,8 +1894,15 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) { .find(|(_, tf)| **tf as usize == f) .and_then(|(t, _)| m.tri(t)) .map(|[a, bb, c]| (a + bb + c) / 3.0); + let takes = bx.takes(&to2(&pts)) + && (bx.crossing + || m.surface_patch(f).iter().all(|&face| { + m.face_edges(u32::try_from(face).unwrap_or(u32::MAX)) + .iter() + .all(|&e| m.edges.get(e).is_some_and(|p| bx.takes(&to2(p)))) + })); if let Some(point) = point - && bx.takes(&to2(&pts)) + && takes { out.push(Sel::Face { body: b.name.clone(), index: f, point }); } @@ -1837,8 +1927,17 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) { } } } + let mut grouped = Vec::new(); + for item in out { + for piece in grouped_selection(&app.session, &item) { + if !grouped.iter().any(|x| crate::selection::same_item(x, &piece)) { + grouped.push(piece); + } + } + } + let out = grouped; if let Some(mut d) = app.dialog.take() { - d.take_box(out, add); + d.take_box(&app.session, out, add); app.dialog = Some(d); return; } @@ -2304,6 +2403,137 @@ fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool { mod tests { use super::*; + fn cylinder_app() -> SolveApp { + let mut s = solvecraft_engine::Session::default(); + s.execute("solid.cylinder", &json!({"radius": 10, "height": 20})).unwrap(); + let mut app = SolveApp::new(s, crate::Services::default()); + app.ui.show_origin = false; + app + } + + #[test] + fn circular_items_measure_as_whole_rims_and_surfaces() { + let mut app = cylinder_app(); + let st = app.session.world_state(); + let b = &st.bodies[0]; + let m = b.mesh(); + let edges: Vec = m + .edges + .iter() + .enumerate() + .filter(|(_, p)| p.len() > 3 && p.iter().all(|q| (q.z - 20.0).abs() < 1e-6)) + .map(|(index, p)| Sel::Edge { body: b.name.clone(), index, point: polyline_mid(p) }) + .collect(); + let result = app.session.measure_items(&edges); + assert_eq!(result["items"].as_array().unwrap().len(), 1); + assert!((result["items"][0]["length_mm"].as_f64().unwrap() - 20.0 * std::f64::consts::PI).abs() < 0.2, "{result}"); + app.session.selection = edges; + let d = crate::dialogs::Dialog::for_command(&app, "inspect.measure").unwrap(); + assert_eq!(d.items().len(), 1); + + let faces = m.edge_faces.iter().zip(&m.seams).find(|(_, seam)| **seam).unwrap().0; + 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); + 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"); + } + + #[test] + fn circular_rim_hover_click_and_dialog_pick_include_both_halves() { + let mut app = cylinder_app(); + let st = app.session.world_state(); + 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).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); + assert_eq!(app.session.selection.len(), 2); + // Selecting both pieces must not draw each highlight twice. + assert_eq!(build_highlight(&app).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); + 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()); + } + // 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)); + 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 (_, sel) = candidate(&app, &hits).unwrap(); + assert!(matches!(sel, Sel::Edge { .. }), "{hits:?}"); + assert_eq!(grouped_selection(&app.session, &sel).len(), 2); + } + } + } + + #[test] + fn curved_face_hover_selection_and_dialogs_cross_the_seam() { + let mut app = cylinder_app(); + let st = app.session.world_state(); + 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; + app.viewport.hover = Some(Hit::Face { body: b.name.clone(), index, point }); + assert_eq!(build_highlight(&app).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); + assert_eq!(app.session.selection.len(), 2); + assert_eq!(build_highlight(&app).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); + 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); + 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]); + app.session.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap(); + let st = app.session.world_state(); + let box_body = st.bodies.last().unwrap(); + for i in 0..box_body.body.face_count() { + assert_eq!(box_body.mesh().surface_patch(i), vec![i]); + } + } + #[test] fn section_outlines_follow_the_cut() { let mut s = solvecraft_engine::Session::default();