fix: tapered extrude of profiles mixing lines and tangent arcs (#546) (#587)

Tapered extrudes accept profiles that mix lines and tangent arcs (slots, D-profiles, rounded rectangles): each arc keeps its centre and angles and its radius moves with the taper, so its side stays an exact cone; lines are offset and mitred. An arc meeting another edge at a corner is still refused. Un-ignores the #546 regression test. Fixes #546.

Co-authored-by: jonaswre <[email protected]>
This commit is contained in:
Jonas Wrede authored and GitHub committed 2026-10-13 04:28:21 -04:00
1 parent 651e8e0853
commit abff7a49bb
3 files changed
+106 -3

No files matched your search

-1
View File
@@ -2923,7 +2923,6 @@ fn a_pattern_of_a_reduced_fillet_rounds_each_copy_alike() {
/// half-round front R 4.6 tangent to them) grows by d = z · tan 5° with mitred corners:
/// A(d) = A₀ + P·d + (2 + π/2)·d², integrated over the 10 mm height.
#[test]
#[ignore = "fails until #546 is fixed"]
fn tapered_extrude_of_a_profile_mixing_lines_and_an_arc() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "D"}));
+74 -1
View File
@@ -494,7 +494,8 @@ pub fn torus(center: Vec3, major: f64, minor: f64) -> Result<Body> {
}
/// Offset every segment of a loop to its right by `d` (outward for a region's outer loop and
/// holes alike). Loops of lines (mitred corners) and loops of arcs on one circle are supported.
/// holes alike). Loops of lines (mitred corners), loops of arcs on one circle, and loops of lines
/// and arcs that meet the arcs tangentially are supported.
fn offset_loop(lp: &Loop2, d: f64) -> Result<Loop2> {
if d == 0.0 {
return Ok(lp.clone());
@@ -518,6 +519,9 @@ fn offset_loop(lp: &Loop2, d: f64) -> Result<Loop2> {
.collect::<Result<Vec<_>>>()?;
return Ok(Loop2 { segs });
}
if lp.segs.iter().any(|s| matches!(s, Seg2::Arc { .. })) {
return offset_lines_and_arcs(lp, d);
}
let n = lp.segs.len();
let mut lines: Vec<(Vec2, Vec2)> = Vec::with_capacity(n);
for s in &lp.segs {
@@ -544,6 +548,75 @@ fn offset_loop(lp: &Loop2, d: f64) -> Result<Loop2> {
Ok(out)
}
/// [`offset_loop`] for a loop of lines and arcs. Each arc keeps its centre and angles and its
/// radius moves by `d` (so its side stays an exact cone), each line moves `d` to its right, and
/// two lines meeting at a corner are mitred. An arc has to meet its neighbours tangentially: at
/// a corner its side would meet the next one along a conic, not a straight ruling.
fn offset_lines_and_arcs(lp: &Loop2, d: f64) -> Result<Loop2> {
let closes = || KernelError::Invalid("the taper closes the profile".into());
let right = |v: Vec2| Vec2::new(v.y, -v.x);
let n = lp.segs.len();
let moved: Vec<Option<Seg2>> = lp
.segs
.iter()
.map(|s| match *s {
Seg2::Line { .. } => Ok(None),
Seg2::Arc { center, radius, start, sweep } => {
let r = radius + sweep.signum() * d;
if r > 1e-6 { Ok(Some(Seg2::Arc { center, radius: r, start, sweep })) } else { Err(closes()) }
}
_ => Err(KernelError::Failed("not supported yet: tapered profiles with splines or conics".into())),
})
.collect::<Result<_>>()?;
// Where each segment starts after the offset.
let mut joints = Vec::with_capacity(n);
for (i, s) in lp.segs.iter().enumerate() {
let k = (i + n - 1) % n;
let (Some(prev), Some(prev_moved), Some(this_moved)) = (lp.segs.get(k), moved.get(k), moved.get(i)) else {
return Err(KernelError::Failed("taper: profile loop".into()));
};
let (tin, tout) = (prev.end_tangent(), s.start_tangent());
let tangent = tin.cross(tout).abs() < 1e-6 && tin.dot(tout) > 0.0;
joints.push(match (prev_moved, this_moved) {
(Some(a), _) if tangent => a.end(),
(None, Some(b)) if tangent => b.start(),
(None, None) => {
let v = s.start();
let (p0, p1) = (v + right(tin) * d, v + right(tout) * d);
let den = tin.cross(tout);
if den.abs() < 1e-12 { p1 } else { p0 + tin * ((p1 - p0).cross(tout) / den) }
}
_ => return Err(KernelError::Failed("not supported yet: tapered profiles where an arc meets another edge at a corner".into())),
});
}
let mut segs = Vec::with_capacity(n);
for (i, s) in lp.segs.iter().enumerate() {
let (Some(&a), Some(&b), Some(m)) = (joints.get(i), joints.get((i + 1) % n), moved.get(i)) else {
return Err(KernelError::Failed("taper: profile loop".into()));
};
match m {
Some(arc) => {
if arc.start().dist(a) > 1e-6 || arc.end().dist(b) > 1e-6 {
return Err(KernelError::Failed("not supported yet: tapered profiles where an arc meets another edge at a corner".into()));
}
segs.push(*arc);
}
None => {
// A line that turned around has been swallowed by its neighbours.
if (b - a).dot(s.end() - s.start()) <= 0.0 {
return Err(closes());
}
segs.push(Seg2::Line { a, b });
}
}
}
let out = Loop2 { segs };
if out.signed_area().signum() != lp.signed_area().signum() || out.signed_area().abs() < 1e-9 {
return Err(closes());
}
Ok(out)
}
/// Extrude one region by `length` along `dir_sign` × the plane normal with a taper angle
/// (radians; positive grows the profile).
pub fn extrude_tapered(plane: &Plane, region: &Region2, length: f64, dir_sign: f64, taper: f64) -> Result<Body> {
+32 -1
View File
@@ -1,6 +1,6 @@
use std::f64::consts::PI;
use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
use crate::*;
@@ -429,6 +429,37 @@ fn tapered_extrudes() {
assert!(extrude_tapered(&Plane::XY, &c, 10.0, 1.0, -60f64.to_radians()).is_err(), "closes the profile");
}
/// A slot (two lines and two half-circles, tangent) tapered inward: every edge moves in by
/// d = z · tan 8°, so A(d) = A₀ − P·d + π·d² (#546). An arc meeting a line at a corner is refused.
#[test]
fn tapered_extrude_of_lines_and_arcs() {
let (r, l, h, a) = (5.0, 20.0, 12.0, 8f64.to_radians());
let slot = Loop2 {
segs: vec![
Seg2::Line { a: Vec2::new(0.0, -r), b: Vec2::new(l, -r) },
Seg2::Arc { center: Vec2::new(l, 0.0), radius: r, start: -PI / 2.0, sweep: PI },
Seg2::Line { a: Vec2::new(l, r), b: Vec2::new(0.0, r) },
Seg2::Arc { center: Vec2::ZERO, radius: r, start: PI / 2.0, sweep: PI },
],
};
let b = extrude_tapered(&Plane::XY, &Region2 { outer: slot, holes: vec![] }, h, 1.0, -a).unwrap();
let (a0, p, t) = (2.0 * r * l + PI * r * r, 2.0 * l + 2.0 * PI * r, a.tan());
let v = a0 * h - p * t * h * h / 2.0 + PI * t * t * h.powi(3) / 3.0;
let m = measure(&b).unwrap();
assert!(rel(m.volume, v) < 5e-4, "{} vs {v}", m.volume);
assert_eq!(m.merged.faces, 6);
// A quarter disc: the arc meets both lines at right angles.
let quarter = Loop2 {
segs: vec![
Seg2::Line { a: Vec2::ZERO, b: Vec2::new(r, 0.0) },
Seg2::Arc { center: Vec2::ZERO, radius: r, start: 0.0, sweep: PI / 2.0 },
Seg2::Line { a: Vec2::new(0.0, r), b: Vec2::ZERO },
],
};
let e = extrude_tapered(&Plane::XY, &Region2 { outer: quarter, holes: vec![] }, h, 1.0, a).unwrap_err();
assert!(e.to_string().contains("arc meets another edge at a corner"), "{e}");
}
#[test]
fn polyhedra_shell_and_draft() {
let b = box_solid(Vec3::ZERO, Vec3::new(50.0, 40.0, 30.0)).unwrap();