diff --git a/crates/ui-egui/src/blend_arrow_tests.rs b/crates/ui-egui/src/blend_arrow_tests.rs new file mode 100644 index 00000000..a2dbfeec --- /dev/null +++ b/crates/ui-egui/src/blend_arrow_tests.rs @@ -0,0 +1,103 @@ +//! Fillet and chamfer drag arrows on outside and inside edges (#1384): the arrow points where +//! the value grows (into the body on an outside edge, into the open corner on an inside one), +//! and dragging out along it increases the value. + +use crate::canvas::{anchor, blend_grow_dir}; +use crate::dialogs::Kind; +use crate::scenario::Harness; +use serde_json::json; +use solidcraft_engine::Sel; +use solidcraft_engine::geom::Vec3; + +/// A 40 x 30 x 20 block with a 20 x 30 x 10 step on its left half: the edge along y at x 20, +/// z 20 is inside (concave); the block's right top edge (x 40, z 20) and the step's top right +/// edge (x 20, z 30) are outside (convex). +fn l_step() -> Harness { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length": 40, "width": 30, "height": 20})).unwrap(); + h.app.run("solid.box", json!({"length": 20, "width": 30, "height": 10, "corner": [0, 0, 20], "operation": "join"})).unwrap(); + h.frames(2); + h +} + +fn edge(h: &Harness, p: Vec3) -> Sel { + let st = h.app.session.world_state(); + let m = st.body("Body1").unwrap().mesh(); + let i = m.edges.iter().position(|e| e.windows(2).any(|w| p.dist_to_segment(w[0], w[1]) < 1e-9)).expect("an edge there"); + Sel::Edge { body: "Body1".into(), index: i, point: p } +} + +fn grow(h: &Harness, s: &Sel) -> Vec3 { + let Sel::Edge { body, index, point } = s else { panic!("an edge") }; + blend_grow_dir(&h.app.session.world_state().body(body).unwrap().mesh(), *index, *point).unwrap() +} + +fn close(a: Vec3, b: Vec3) -> bool { + a.dist(b) < 1e-6 +} + +const INSIDE: Vec3 = Vec3::new(20.0, 15.0, 20.0); +const OUTSIDE: Vec3 = Vec3::new(40.0, 15.0, 20.0); +const STEP_TOP: Vec3 = Vec3::new(20.0, 15.0, 30.0); + +/// The direction function on its own: the corner's open side on the inside edge, into the +/// material on the outside ones, each edge of a mixed chain on its own. +#[test] +fn grow_direction_follows_each_edges_convexity() { + let h = l_step(); + let s = std::f64::consts::FRAC_1_SQRT_2; + let open = Vec3::new(s, 0.0, s); + assert!(close(grow(&h, &edge(&h, INSIDE)), open), "inside: {:?}", grow(&h, &edge(&h, INSIDE))); + assert!(close(grow(&h, &edge(&h, OUTSIDE)), Vec3::new(-s, 0.0, -s)), "outside: {:?}", grow(&h, &edge(&h, OUTSIDE))); + // Same face normals as the inside edge, but outside: the opposite way. + assert!(close(grow(&h, &edge(&h, STEP_TOP)), -open), "step top: {:?}", grow(&h, &edge(&h, STEP_TOP))); +} + +/// A plain box's outside edge keeps its arrow into the body. +#[test] +fn box_edge_arrow_still_points_into_the_body() { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length": 40, "width": 30, "height": 20})).unwrap(); + let e = edge(&h, Vec3::new(20.0, 0.0, 20.0)); + for id in ["solid.fillet", "solid.chamfer"] { + h.app.session.selection = vec![e.clone()]; + h.app.start(id); + let dir = anchor(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().1.unwrap(); + assert!(dir.y > 0.5 && dir.z < -0.5, "{id}: {dir:?}"); + h.app.dialog = None; + } +} + +/// The value of the open fillet or chamfer window. +fn value(h: &Harness) -> f64 { + let Some(Kind::Fillet { radius, .. }) = h.app.dialog.as_ref().map(|d| &d.kind) else { panic!("a fillet window") }; + h.app.session.doc.eval(radius, solidcraft_engine::doc::expr::Kind::Length).unwrap() +} + +/// On an inside edge (and on an outside one, a mixed chain's first edge either way), the arrow +/// points along the edge's own grow direction and dragging outward along it increases the value. +#[test] +fn dragging_out_along_the_arrow_grows_fillet_and_chamfer_on_both_kinds_of_edge() { + for first in [INSIDE, OUTSIDE] { + for id in ["solid.fillet", "solid.chamfer"] { + let mut h = l_step(); + h.step(&json!({"call": "ui.view", "params": {"view": "iso"}})).unwrap(); + h.step(&json!({"call": "ui.view", "params": {"view": "fit"}})).unwrap(); + let chain = if first == INSIDE { vec![edge(&h, INSIDE), edge(&h, OUTSIDE)] } else { vec![edge(&h, OUTSIDE), edge(&h, INSIDE)] }; + h.app.session.selection = chain; + h.app.start(id); + h.frames(3); + let shown = h.app.dialog.as_ref().unwrap().inputs[0].items[0].clone(); + assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items.len(), 2); + let want = grow(&h, &shown); + assert!(close(want, grow(&h, &edge(&h, first))) || matches!(&shown, Sel::Edge { point, .. } if *point != first)); + let (at, dir) = anchor(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + assert!(close(dir.unwrap(), want), "{id} at {first:?}: {dir:?} vs {want:?}"); + let before = value(&h); + let to = at + want * 6.0; + h.step(&json!({"drag": [{"handle": "arrow"}, {"world": [to.x, to.y, to.z]}], "ctrl": true})).unwrap(); + let after = value(&h); + assert!(after > before && (after - 6.0).abs() < 0.3, "{id} at {first:?}: {before} -> {after}"); + } + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 93cc4f54..2cc38d3f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -116,7 +116,13 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option)> { (at, normal.or(dir.map(|d| -d))) } Kind::Section { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. } => (at, normal), - // Fillet radii, chamfer distances and shell thickness grow into the body. + // Fillet radii and chamfer distances grow the way `blend_grow_dir` gives (into the body + // on an outside edge, into the open corner on an inside one); shell thickness grows into + // the body. + Kind::Fillet { .. } if matches!(first, Sel::Edge { .. }) => { + let Sel::Edge { body, index, .. } = first else { return None }; + (at, st.body(body).and_then(|b| blend_grow_dir(&b.mesh(), *index, at))) + } Kind::Fillet { .. } | Kind::Shell { .. } => (at, normal.map(|n| -n)), Kind::Sheet(_) => (at, crate::dialogs_sheet::arrow(app, d).or(normal)), Kind::Plastic(_) => (at, normal), @@ -178,23 +184,46 @@ fn reference_height(app: &SolveApp, d: &Dialog, label: &str, plane: &solidcraft_ Some(solidcraft_engine::doc::object_height(&app.session.world_state(), plane, centre, p)) } -/// The outward direction between the two faces at an edge (a fillet's radius arrow points the -/// other way, into the body). -fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option { +/// The two faces at an edge, near `p`: for each, the normal and centroid of its triangle nearest +/// `p`. +fn edge_sides(m: &Mesh, index: usize, p: Vec3) -> Option> { let faces = m.edge_faces.get(index)?; - let mut sum = Vec3::ZERO; - for f in faces { - let n = m - .triangles - .iter() - .zip(&m.tri_face) - .filter(|(_, tf)| *tf == f) - .filter_map(|(t, _)| m.tri(t)) - .min_by(|a, b| ((a[0] + a[1] + a[2]) / 3.0).dist(p).total_cmp(&((b[0] + b[1] + b[2]) / 3.0).dist(p))) - .and_then(|[a, b, c]| (b - a).cross(c - a).normalized())?; - sum += n; - } - sum.normalized() + faces + .iter() + .map(|f| { + m.triangles + .iter() + .zip(&m.tri_face) + .filter(|(_, tf)| *tf == f) + .filter_map(|(t, _)| m.tri(t)) + .min_by(|a, b| ((a[0] + a[1] + a[2]) / 3.0).dist(p).total_cmp(&((b[0] + b[1] + b[2]) / 3.0).dist(p))) + .and_then(|[a, b, c]| Some(((b - a).cross(c - a).normalized()?, (a + b + c) / 3.0))) + }) + .collect() +} + +/// The outward direction between the two faces at an edge: the mean of their normals. +fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option { + edge_sides(m, index, p)?.iter().fold(Vec3::ZERO, |sum, (n, _)| sum + *n).normalized() +} + +/// Whether the edge is an inside (concave) one: each face near it lies in front of the other's +/// plane, where on an outside edge it lies behind. Only the offset across the edge counts, since +/// each normal is square to the edge. +fn edge_concave(m: &Mesh, index: usize, p: Vec3) -> Option { + let sides = edge_sides(m, index, p)?; + let [(n1, c1), (n2, c2)] = sides.as_slice() else { return None }; + let s = (*c1 - p).dot(*n2) + (*c2 - p).dot(*n1); + Some(s > 0.0) +} + +/// The direction a fillet's radius or a chamfer's distance grows from an edge, along the bisector +/// of its faces: into the body on an outside edge, where the blend takes material away, and out +/// into the open corner on an inside edge, where it adds material (#1384). Dragging along it +/// increases the value either way. +pub(crate) fn blend_grow_dir(m: &Mesh, index: usize, p: Vec3) -> Option { + let n = edge_bisector(m, index, p)?; + Some(if edge_concave(m, index, p).unwrap_or(false) { n } else { -n }) } /// Whether a dynamically registered canvas handle owns this point before navigation. @@ -609,8 +638,9 @@ fn radius_spline_handles(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Pain let (spline, start) = law.as_ref()?; let other = if start.dist(*a) <= start.dist(*b) { *b } else { *a }; let mid = *start + (other - *start) * 0.5; - // Into the body, between the two faces (the edge is straight between planar faces). - let n = -edge_bisector(mesh, *index, mid)?; + // Between the two faces, the way this edge's radius grows: into the body on an outside + // edge, into the corner on an inside one (the edge is straight between planar faces). + let n = blend_grow_dir(mesh, *index, mid)?; let curve = (0..=48) .filter_map(|k| { let u = f64::from(k) / 48.0; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d3a73d9c..6cea2e4d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -89,6 +89,8 @@ mod file_load; mod file_picker; pub use file_picker::FilePickerFuture; #[cfg(test)] +mod blend_arrow_tests; +#[cfg(test)] mod file_load_ui_tests; #[cfg(test)] mod fillet_spline_handle_tests;