From 6eaf7458aba0a3f6e075b85093ee703a32a8a85e Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 15 Oct 2026 08:58:51 -0400 Subject: [PATCH] Fillet/chamfer: point the drag arrow into the corner on inside edges (#1394) * Fillet/chamfer: point the drag arrow into the corner on inside edges (#1384) The radius/distance arrow always pointed against the faces' outward bisector, which is into the body on an outside edge but away from the corner on an inside one, where the blend adds material. The arrow and the spline radius handles now take each edge's convexity into account, so dragging out along the arrow grows the value on both kinds of edge, and each edge of a mixed chain follows its own. Co-Authored-By: Claude Opus 5.5 * Tests: no unreachable! in the blend arrow helper Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/blend_arrow_tests.rs | 103 ++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 68 +++++++++++----- crates/ui-egui/src/lib.rs | 2 + 3 files changed, 154 insertions(+), 19 deletions(-) create mode 100644 crates/ui-egui/src/blend_arrow_tests.rs 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;