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 <[email protected]>

* Tests: no unreachable! in the blend arrow helper

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-15 08:58:51 -04:00
1 parent ad30a2350e
commit 6eaf7458ab
3 files changed
+154 -19

No files matched your search

+103
View File
@@ -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}");
}
}
}
+49 -19
View File
@@ -116,7 +116,13 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
(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<Vec3> {
/// 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<Vec<(Vec3, Vec3)>> {
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<Vec3> {
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<bool> {
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<Vec3> {
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;
+2
View File
@@ -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;