Extrude: dragging the taper stops where the profile would close (#1602) (#1614)

Thanks @DuBearte.
This commit is contained in:
DuBearte authored and GitHub committed 2026-10-16 03:00:52 -04:00
1 parent 25c89a706e
commit 61678654c2
2 files changed
+170 -5

No files matched your search

+93
View File
@@ -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<Vec<Vec2>> = 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<Vec2>]) -> 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;
+77 -5
View File
@@ -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(&regions, 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));
}
}