fix: select complete circular rims and curved surfaces in 3D
Group kernel pieces across circular rims and hidden surface seams for hovering, selection, dialogs, and measurement. Preserve physical references and cover full-item toggling and picking with regression tests. Fixes #88
This commit is contained in:
1 parent
e042a7b555
commit
acf4669f73
6 files changed
+424
-34
No files matched your search
@@ -55,13 +55,13 @@ fn densify(pts: &[Vec3]) -> Vec<Vec3> {
|
||||
|
||||
/// 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<Vec<Vec3>> {
|
||||
fn edge_index(m: &Mesh, index: usize, point: Vec3) -> Option<usize> {
|
||||
let near = |e: &Vec<Vec3>| 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<Vec3>, 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<Vec3>> = 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::<Vec<_>>();
|
||||
let area: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum();
|
||||
let normals: Vec<Vec3> = 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<Value> {
|
||||
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<Sel> = list.iter().map(|v| serde_json::from_value::<Sel>(v.clone()).map_err(|e| bad(cmd, e.to_string()))).collect::<Result<_>>()?;
|
||||
let st = s.world_state();
|
||||
let mut sels = Vec::new();
|
||||
for value in list {
|
||||
let mut sel = serde_json::from_value::<Sel>(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)"))?);
|
||||
|
||||
@@ -82,6 +82,71 @@ impl Mesh {
|
||||
pub fn face_edges(&self, f: u32) -> Vec<usize> {
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
|
||||
@@ -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<Vec3> {
|
||||
(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};
|
||||
|
||||
@@ -480,10 +480,12 @@ impl Dialog {
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.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<Sel>, add: bool) {
|
||||
pub fn take_box(&mut self, s: &Session, sels: Vec<Sel>, 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+247
-17
@@ -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<Sel>, 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<Sel> = 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<Sel> =
|
||||
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<Sel>, 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<Sel> {
|
||||
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<Sel> = 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();
|
||||
|
||||
Reference in new issue
Block a user