diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ab539561..2a374503 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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"})); diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index 2eaa52f1..b69f6579 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -494,7 +494,8 @@ pub fn torus(center: Vec3, major: f64, minor: f64) -> Result { } /// 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 { if d == 0.0 { return Ok(lp.clone()); @@ -518,6 +519,9 @@ fn offset_loop(lp: &Loop2, d: f64) -> Result { .collect::>>()?; 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 { 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 { + 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> = 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::>()?; + // 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 { diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index a3cd0f7f..f7d93bb2 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -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();