From 61678654c25e1f94da11d116f0c763cf5d7ff994 Mon Sep 17 00:00:00 2001 From: DuBearte <130190089+DuBearte@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:00:52 -0400 Subject: [PATCH] Extrude: dragging the taper stops where the profile would close (#1602) (#1614) Thanks @DuBearte. --- crates/geom/src/profile.rs | 93 ++++++++++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 82 +++++++++++++++++++++++++++++-- 2 files changed, 170 insertions(+), 5 deletions(-) diff --git a/crates/geom/src/profile.rs b/crates/geom/src/profile.rs index 22910989..4ff22666 100644 --- a/crates/geom/src/profile.rs +++ b/crates/geom/src/profile.rs @@ -372,6 +372,10 @@ pub struct Loop2 { } impl Loop2 { + /// The radius of the largest circle inside the loop, to about `tol` ([`Region2::inradius`]). + pub fn inradius(&self, tol: f64) -> f64 { + max_clearance(&[self.polyline(tol)]) + } /// Full circle as two half arcs (counter-clockwise). pub fn circle(center: Vec2, radius: f64) -> Loop2 { Loop2 { @@ -505,6 +509,79 @@ impl Region2 { pub fn contains(&self, p: Vec2) -> bool { self.outer.contains(p) && !self.holes.iter().any(|h| h.contains(p)) } + /// The radius of the largest circle inside the region: how far its sides can move inwards + /// before they meet (a taper going in closes it there). Found by a grid search refined + /// around the best point, to about `tol`. + pub fn inradius(&self, tol: f64) -> f64 { + let loops: Vec> = std::iter::once(&self.outer).chain(&self.holes).map(|l| l.polyline(tol)).collect(); + max_clearance(&loops) + } +} + +/// The largest distance from a point inside `loops` (even-odd) to their sides, by a grid search +/// over their box refined around the best point. Zero for an empty or degenerate outline. +fn max_clearance(loops: &[Vec]) -> f64 { + let sides: Vec<(Vec2, Vec2)> = loops + .iter() + .flat_map(|pl| (0..pl.len()).filter_map(move |i| Some((*pl.get(i)?, *pl.get((i + 1) % pl.len())?)))) + .filter(|(a, b)| a.dist(*b) > 0.0) + .collect(); + let (Some(lo), Some(hi)) = ( + sides.iter().map(|s| s.0).reduce(|a, b| Vec2::new(a.x.min(b.x), a.y.min(b.y))), + sides.iter().map(|s| s.0).reduce(|a, b| Vec2::new(a.x.max(b.x), a.y.max(b.y))), + ) else { + return 0.0; + }; + let inside = |p: Vec2| { + let mut odd = false; + for (a, b) in &sides { + if (a.y > p.y) != (b.y > p.y) && p.x < a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x) { + odd = !odd; + } + } + odd + }; + let clearance = |p: Vec2| -> f64 { + if !inside(p) { + return 0.0; + } + sides + .iter() + .map(|(a, b)| { + let ab = *b - *a; + let t = ((p - *a).dot(ab) / ab.len2()).clamp(0.0, 1.0); + p.dist(*a + ab * t) + }) + .fold(f64::INFINITY, f64::min) + }; + const N: usize = 32; + let size = hi - lo; + let mut best = (0.0, (lo + hi) * 0.5); + for i in 0..=N { + for j in 0..=N { + let p = Vec2::new(lo.x + size.x * i as f64 / N as f64, lo.y + size.y * j as f64 / N as f64); + let c = clearance(p); + if c > best.0 { + best = (c, p); + } + } + } + // Refine: try the eight neighbours, halving the step whenever none is better. + let mut step = size.x.max(size.y) / N as f64; + for _ in 0..60 { + if step < size.x.max(size.y) * 1e-9 { + break; + } + let around = [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0), (1.0, 1.0), (1.0, -1.0), (-1.0, 1.0), (-1.0, -1.0)]; + match around.iter().map(|(dx, dy)| best.1 + Vec2::new(dx * step, dy * step)).map(|p| (clearance(p), p)).max_by(|a, b| a.0.total_cmp(&b.0)) { + Some(n) if n.0 > best.0 => best = n, + _ => step *= 0.5, + } + } + best.0 +} + +impl Region2 { /// Triangles covering the region (for filled display), by slabs between the vertex /// heights: inside each slab the boundary edges don't cross, so consecutive crossings pair /// up into trapezoids. @@ -672,6 +749,22 @@ mod tests { Region2 { outer: l, holes: Vec::new() } } + /// #1602: the largest circle inside a region, which bounds how far a taper can go. + #[test] + fn inradius_of_rectangles_circles_and_rings() { + let close = |a: f64, b: f64| (a - b).abs() < 1e-3 * b.max(1.0); + let rect = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 4.0), Vec2::new(0.0, 4.0)]); + assert!(close(solid(rect.clone()).inradius(1e-3), 2.0), "{}", solid(rect.clone()).inradius(1e-3)); + assert!(close(rect.inradius(1e-3), 2.0)); + let disc = Loop2::circle(Vec2::new(3.0, -2.0), 5.0); + assert!(close(solid(disc.clone()).inradius(1e-4), 5.0), "{}", solid(disc).inradius(1e-4)); + // A ring 10 to 6: the material is 4 wide (radius 2); the hole alone has radius 6. + let ring = Region2 { outer: Loop2::circle(Vec2::ZERO, 10.0), holes: vec![Loop2::circle(Vec2::ZERO, 6.0).reversed()] }; + assert!(close(ring.inradius(1e-4), 2.0), "{}", ring.inradius(1e-4)); + assert!(close(ring.holes[0].inradius(1e-4), 6.0)); + assert_eq!(Region2::default().inradius(1e-3), 0.0); + } + #[test] fn regions_are_apart_only_when_nothing_meets_or_holds_another() { let gap = 1e-3; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 28230881..300a5d45 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -425,7 +425,20 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, let px = 2.0 * half_height / f64::from(proj.rect.height().max(1.0)); let ang = app.session.doc.eval(taper, ValueKind::Angle).ok().filter(|v| v.is_finite()).unwrap_or(0.0); let before = taper.clone(); - taper_handle(ui, painter, proj, base - e * (40.0 * px), n * l, -e, ang, taper); + let st = app.session.world_state(); + let regions: Vec<&solidcraft_engine::geom::Region2> = d + .inputs + .first() + .map(|i| &i.items[..]) + .unwrap_or(&[]) + .iter() + .filter_map(|x| match x { + Sel::Profile { sketch, index } => st.sketch(*sketch).and_then(|ss| ss.profiles.get(*index)).map(|pr| &pr.region), + _ => None, + }) + .collect(); + let limits = taper_limits(®ions, l.abs()); + taper_handle(ui, painter, proj, base - e * (40.0 * px), n * l, -e, ang, limits, taper); if *taper != before { *taper_present = true; } @@ -857,16 +870,42 @@ fn measure_components(app: &SolveApp, painter: &egui::Painter, proj: &Proj, a: V } } +/// The tapers (degrees, least and most) a drag of the taper handle may reach for `regions` +/// extruded `height` (#1602): going in, the sides close the material where they have moved its +/// inradius; going out, they close the smallest hole. Each stops just short, so the drag ends at +/// the largest taper that builds instead of one that fails. A typed angle is not limited. +pub(crate) fn taper_limits(regions: &[&solidcraft_engine::geom::Region2], height: f64) -> (f64, f64) { + const MAX: f64 = 89.0; + if !(height > 1e-9) || regions.is_empty() { + return (-MAX, MAX); + } + let at = |r: f64| (0.99 * r / height).atan().to_degrees().min(MAX); + let inward = regions.iter().map(|r| r.inradius(1e-3)).fold(f64::INFINITY, f64::min); + let outward = regions.iter().flat_map(|r| &r.holes).map(|h| h.inradius(1e-3)).fold(f64::INFINITY, f64::min); + (if inward.is_finite() { -at(inward) } else { -MAX }, if outward.is_finite() { at(outward) } else { MAX }) +} + /// The taper handle: a line from `foot` along the extrude (`span`) leaning by `angle` towards /// `e`, with a ring at its end; dragging the ring sideways sets the angle (1° steps, Alt/Ctrl: -/// free). +/// free), within `limits` ([`taper_limits`]). #[allow(clippy::too_many_arguments)] -fn taper_handle(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, foot: Vec3, span: Vec3, e: Vec3, angle: f64, value: &mut String) { +fn taper_handle( + ui: &mut egui::Ui, + painter: &egui::Painter, + proj: &Proj, + foot: Vec3, + span: Vec3, + e: Vec3, + angle: f64, + limits: (f64, f64), + value: &mut String, +) { let t = Tokens::get(); let l = span.len(); let end = foot + span + e * (l * angle.tan().clamp(-10.0, 10.0)); let (Some(fs), Some(es)) = (proj.to_screen(foot), proj.to_screen(end)) else { return }; let r = ui.interact(egui::Rect::from_center_size(es, vec2(16.0, 16.0)), egui::Id::new("sc_taper"), egui::Sense::drag()); + crate::scenario::publish_handle("taper", es); let hot = r.hovered() || r.dragged(); let col = if hot { t.accent } else { t.manipulator }; painter.add(egui::Shape::dashed_line(&[fs, es], Stroke::new(1.2, col), 5.0, 3.0)); @@ -879,8 +918,12 @@ fn taper_handle(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, foot: V && let Some(s) = along(proj, p, foot + span, e) { let mut deg = s.atan2(l).to_degrees(); - if !ui.input(|i| i.modifiers.alt || i.modifiers.ctrl || i.modifiers.command) { - deg = deg.round(); + let (lo, hi) = limits; + if ui.input(|i| i.modifiers.alt || i.modifiers.ctrl || i.modifiers.command) { + deg = deg.clamp(lo, hi); + } else { + // Whole degrees, rounded towards zero at a limit so they stay inside it. + deg = deg.round().clamp(lo.ceil().min(0.0), hi.floor().max(0.0)); } let txt = format!("{deg:.1}"); *value = format!("{} deg", txt.trim_end_matches('0').trim_end_matches('.')); @@ -1262,3 +1305,32 @@ mod tests { assert_eq!(format_len(-0.0001), "0 mm"); } } + +#[cfg(test)] +mod taper_limit_tests { + use super::taper_limits; + use solidcraft_engine::geom::{Loop2, Region2, Vec2}; + + /// #1602: a drag stops just short of the taper that closes the profile, either way. + #[test] + fn a_taper_drag_stops_before_the_profile_closes() { + let close = |a: f64, b: f64| (a - b).abs() < 0.05; + let deg = |r: f64, h: f64| (0.99 * r / h).atan().to_degrees(); + // A 10 × 4 rectangle 26 high: going in closes at its half width; going out never does. + let rect = Region2 { + outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 4.0), Vec2::new(0.0, 4.0)]), + holes: Vec::new(), + }; + let (lo, hi) = taper_limits(&[&rect], 26.0); + assert!(close(lo, -deg(2.0, 26.0)) && hi == 89.0, "{lo} {hi}"); + // A ring 10 to 6, 10 high: in, its 4 mm wall; out, its hole of radius 6. + let ring = Region2 { outer: Loop2::circle(Vec2::ZERO, 10.0), holes: vec![Loop2::circle(Vec2::ZERO, 6.0).reversed()] }; + let (lo, hi) = taper_limits(&[&ring], 10.0); + assert!(close(lo, -deg(2.0, 10.0)) && close(hi, deg(6.0, 10.0)), "{lo} {hi}"); + // The tightest of several profiles; no profile or no height, no limit. + let (lo, _) = taper_limits(&[&ring, &rect], 10.0); + assert!(close(lo, -deg(2.0, 10.0))); + assert_eq!(taper_limits(&[], 10.0), (-89.0, 89.0)); + assert_eq!(taper_limits(&[&rect], 0.0), (-89.0, 89.0)); + } +}