From beb3ea01e7c23d28a8b2d36de1f93de016ca7b39 Mon Sep 17 00:00:00 2001 From: phanoob Date: Wed, 14 Oct 2026 23:55:58 +0200 Subject: [PATCH] Taper: the review of #1014 (spline sides follow their offset, no silent limits, face names kept) (#1204) Taper: the review of #1014 (spline sides follow their offset, no silent limits, face names kept). --- crates/doc/src/eval.rs | 9 +- crates/engine/src/cmd/features_more_tests.rs | 19 + crates/kernel/src/build.rs | 291 +++++++++++-- crates/kernel/src/tests.rs | 52 +++ docs/book/src/guide/features.md | 9 +- .../two_tapered_bosses_filleted.json | 385 ++++++++++++++++++ 6 files changed, 721 insertions(+), 44 deletions(-) create mode 100644 tests/taper/before-1014/two_tapered_bosses_filleted.json diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 25872cfa..75c78e6c 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -2656,6 +2656,8 @@ fn feature_tools_with_constructor( ]; // How far a tool grows past its profile at either end, at most. let mut growth: f64 = 0.0; + // A face of the targets in the profile's plane (the same for every profile). + let on_face = matches!(operation, Operation::Join | Operation::Cut) && face_level_at(st, &ss.plane, from, targets); let tools: Vec = regions .iter() .map(|r| { @@ -2669,12 +2671,7 @@ fn feature_tools_with_constructor( // A start that can't run on (the profile hangs over the face's edge): // the sides stand straight for a hair first, so the boolean can push the // face the tool stands on into the body. - let foot = if a >= 0.0 && matches!(operation, Operation::Join | Operation::Cut) && face_level_at(st, &ss.plane, from, targets) - { - TAPER_FOOT.min((b - a) * 0.01) - } else { - 0.0 - }; + let foot = if a >= 0.0 && on_face { TAPER_FOOT.min((b - a) * 0.01) } else { 0.0 }; let body = kernel::extrude_tapered_between(&start_plane, r, a, b, foot, sign, taper)?; growth = growth.max(a.abs().max(b.abs()) * taper.tan().abs()); Ok(crate::lineage::ProfileRoles { sketch: Some(&ss), region: Some(r), caps: caps.clone() }.tool(body, None)) diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index 7e2792bc..a437efdb 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -958,3 +958,22 @@ fn fillet_two_edges_meeting_at_a_corner_one_running_into_the_arc_wall() { let after = planned(&s); assert!(rel(before - after, removed) < 1e-3, "removed {} vs {removed}", before - after); } + +/// A design saved before tapered sides were built through `taper_loop` (#1014), by the build +/// before it: two tapered bosses apart from each other (now joined in one boolean), a fillet on +/// a sloping side edge of one and on the top edge of the other, both saved by edge name. They +/// still find their edges, and the part comes out the same. +#[test] +fn fillets_on_tapered_bosses_saved_before_still_find_their_edges() { + let text = include_str!("../../../../tests/taper/before-1014/two_tapered_bosses_filleted.json"); + let mut s = Session::new(solidcraft_doc::Document::from_json(text).unwrap()); + let volume = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap(); + for name in ["Extrude2", "Fillet1", "Fillet2"] { + let id = s.doc.find_feature(name).unwrap().id; + let r = s.model.result(id).unwrap(); + assert!(r.error.is_none() && r.warning.is_none(), "{name}: {:?} {:?}", r.error, r.warning); + } + // The build that saved it measured 69454.885209 mm³. + assert!((volume - 69454.8852092132).abs() < 1e-3, "{volume}"); + assert_eq!(s.world_state().bodies.len(), 1); +} diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index 2bae7fe7..a1c02076 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -594,6 +594,199 @@ fn offset_polygon(ctrl: &[Vec2], a: Vec2, b: Vec2, d: f64) -> Option> out.iter().all(|p| p.is_finite()).then_some(out) } +/// Samples a moved free curve is fitted to, and checked against. +const OFFSET_SAMPLES: usize = 16; + +/// Where the point at `t` of the free curve `s` goes once moved `d` to its right (along its +/// normal there). `None` where it has no tangent. +fn true_offset(s: &Seg2, t: f64, d: f64) -> Option { + let tangent = s.derivative(t).normalized()?; + Some(s.point_at(t) + Vec2::new(tangent.y, -tangent.x) * d) +} + +/// The free curve `s` moved `d` to its right, as one curve of its kind from `a` to `b` (where +/// its moved neighbours meet it; its own moved ends where it runs on into them). It is fitted to +/// the true offset between the curve's own moved ends: a cubic keeps its end tangents and takes +/// the handle lengths that fit best (least squares over [`OFFSET_SAMPLES`] points); a conic or a +/// higher Bézier moves its control polygon ([`offset_polygon`]), its middle then put on the true +/// offset. Its ends then go to `a` and `b`, along its end tangents (where moved neighbours meet +/// it), their handles kept pointing the same way. Close for a curve that turns little against +/// `d`; [`offset_splits`] cuts the others into such pieces first. +fn offset_curve(s: &Seg2, a: Vec2, b: Vec2, d: f64) -> Option { + let (n0, n1) = (true_offset(s, 0.0, d)?, true_offset(s, 1.0, d)?); + let (t0, t3) = (s.start_tangent(), s.end_tangent()); + let middle = |moved: &Seg2| Some(true_offset(s, 0.5, d)? - moved.point_at(0.5)); + // The control points fitted between the natural ends. + let mut ctrl: Vec = match *s { + Seg2::Cubic { p0, p1, p2, p3 } => { + // The moved cubic n0·(B0+B1) + n1·(B2+B3) + α·t0·B1 − β·t3·B2: linear in α, β. + let (mut m, mut r) = ([[0.0f64; 2]; 2], [0.0f64; 2]); + for k in 1..OFFSET_SAMPLES { + let t = k as f64 / OFFSET_SAMPLES as f64; + let u = 1.0 - t; + let (b0, b1, b2, b3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + let rest = true_offset(s, t, d)? - (n0 * (b0 + b1) + n1 * (b2 + b3)); + let (c1, c2) = (t0 * b1, t3 * -b2); + m[0][0] += c1.dot(c1); + m[0][1] += c1.dot(c2); + m[1][1] += c2.dot(c2); + r[0] += c1.dot(rest); + r[1] += c2.dot(rest); + } + let det = m[0][0] * m[1][1] - m[0][1] * m[0][1]; + let (alpha, beta) = if det.abs() > 1e-12 * (m[0][0] * m[1][1]).max(1e-30) { + ((r[0] * m[1][1] - r[1] * m[0][1]) / det, (m[0][0] * r[1] - m[0][1] * r[0]) / det) + } else { + (f64::NAN, f64::NAN) + }; + if alpha.is_finite() && beta.is_finite() && alpha > 0.0 && beta > 0.0 { + vec![n0, n0 + t0 * alpha, n1 - t3 * beta, n1] + } else { + // Handles that would turn back: the moved control polygon, its middle on the offset. + let mut q = offset_polygon(&[p0, p1, p2, p3], n0, n1, d)?; + let [q0, q1, q2, q3] = q[..] else { return None }; + let shift = middle(&Seg2::Cubic { p0: q0, p1: q1, p2: q2, p3: q3 })? * (8.0 / 6.0); + for point in q.iter_mut().take(3).skip(1) { + *point += shift; + } + q + } + } + Seg2::Conic { a: c0, apex, b: c1, w } => { + let mut q = offset_polygon(&[c0, apex, c1], n0, n1, d)?; + let [q0, qa, q1] = q[..] else { return None }; + // The middle is (a + 2w·apex + b) / (2 + 2w). + let shift = middle(&Seg2::Conic { a: q0, apex: qa, b: q1, w })? * ((2.0 + 2.0 * w) / (2.0 * w)); + if let Some(apex) = q.get_mut(1) { + *apex += shift; + } + q + } + Seg2::Bezier { n, p } => { + let k = (n as usize).clamp(1, solidcraft_geom::MAX_BEZIER_DEGREE); + let mut q = offset_polygon(p.get(..=k)?, n0, n1, d)?; + let mut all = p; + for (dst, src) in all.iter_mut().zip(&q) { + *dst = *src; + } + // The middle weighs the inner points by binomials summing to 2^k − 2. + let shift = middle(&Seg2::Bezier { n, p: all })? * (2f64.powi(k as i32) / (2f64.powi(k as i32) - 2.0)); + for point in q.iter_mut().take(k).skip(1) { + *point += shift; + } + q + } + Seg2::Line { .. } | Seg2::Arc { .. } => return None, + }; + // The ends to where the neighbours meet; a handle the move would turn round goes with it. + let last = ctrl.len().checked_sub(1)?; + if last < 2 { + return None; + } + let (ea, eb) = (a - n0, b - n1); + if let Some(h) = ctrl.get_mut(1) + && (*h - a).dot(t0) <= 0.0 + { + *h += ea; + } + if let Some(h) = ctrl.get_mut(last - 1) + && (b - *h).dot(t3) <= 0.0 + { + *h += eb; + } + if let Some(first) = ctrl.first_mut() { + *first = a; + } + if let Some(end) = ctrl.last_mut() { + *end = b; + } + Some(match (*s, &ctrl[..]) { + (Seg2::Cubic { .. }, &[p0, p1, p2, p3]) => Seg2::Cubic { p0, p1, p2, p3 }, + (Seg2::Conic { w, .. }, &[a, apex, b]) => Seg2::Conic { a, apex, b, w }, + (Seg2::Bezier { n, p }, q) => { + let mut all = p; + for (dst, src) in all.iter_mut().zip(q) { + *dst = *src; + } + Seg2::Bezier { n, p: all } + } + _ => return None, + }) +} + +/// How far a moved free curve strays from the free curve `s`'s true offset by `d`: the largest +/// distance from its points to the true offset's (on polylines of [`OFFSET_SAMPLES`] spans). +pub(crate) fn offset_error(s: &Seg2, d: f64) -> Option { + let (a, b) = (true_offset(s, 0.0, d)?, true_offset(s, 1.0, d)?); + let moved = offset_curve(s, a, b, d)?; + let n = OFFSET_SAMPLES * 2; + let truth: Vec = (0..=n).map(|k| true_offset(s, k as f64 / n as f64, d)).collect::>()?; + let near = |p: Vec2| { + truth + .iter() + .zip(truth.iter().skip(1)) + .map(|(x, y)| Seg2::Line { a: *x, b: *y }) + .map(|l| l.point_at(l.closest_param(p)).dist(p)) + .fold(f64::INFINITY, f64::min) + }; + Some((0..=n).map(|k| near(moved.point_at(k as f64 / n as f64))).fold(0.0, f64::max)) +} + +/// How close a moved free curve is kept to its true offset by `d`: 2 % of it, or 10 µm (a +/// slope's offset is a fraction of a millimetre on text: finer would cut letters into many +/// pieces, and every piece a face the booleans meet). +pub(crate) fn offset_tolerance(d: f64) -> f64 { + (d.abs() * 2e-2).max(1e-2) +} + +/// Deepest halving of a free curve in [`offset_splits`] (at most 2^6 pieces). +const MAX_OFFSET_DEPTH: u32 = 6; + +/// Whether the free curve `s` bends anywhere tighter than `d` (its radius of curvature below +/// |d|, on [`OFFSET_SAMPLES`] points): moved that far towards its centre, its true offset folds +/// over itself there. +fn tighter_than(s: &Seg2, d: f64) -> bool { + let h = 1e-4; + (0..=OFFSET_SAMPLES).any(|k| { + let t = (k as f64 / OFFSET_SAMPLES as f64).clamp(h, 1.0 - h); + let (v, a) = (s.derivative(t), (s.derivative(t + h) - s.derivative(t - h)) * (0.5 / h)); + let speed = v.len(); + speed > 1e-12 && v.cross(a).abs() * d.abs() >= speed.powi(3) + }) +} + +/// The loop `lp` with each free curve cut into pieces whose offset by up to `d` (either way) +/// [`offset_curve`] fits within a small part of `d`, and the segment of `lp` each piece comes +/// from. Lines and arcs stay whole, and so does a piece bending tighter than `d` +/// ([`tighter_than`]): its true offset folds there, and a piece following it would carry the +/// fold, where one moved piece smooths it over. +pub(crate) fn offset_splits(lp: &Loop2, d: f64) -> (Loop2, Vec) { + let tol = offset_tolerance(d); + let mut segs = Vec::with_capacity(lp.segs.len()); + let mut from = Vec::with_capacity(lp.segs.len()); + for (i, s) in lp.segs.iter().enumerate() { + if s.is_line_or_arc() || d == 0.0 { + segs.push(*s); + from.push(i); + continue; + } + // Spans still to check, the last pushed checked first: the right half goes in first. + let mut todo = vec![(*s, 0u32)]; + while let Some((piece, depth)) = todo.pop() { + let close = |d: f64| offset_error(&piece, d).is_some_and(|e| e <= tol); + if depth < MAX_OFFSET_DEPTH && !tighter_than(&piece, d) && !(close(d) && close(-d)) { + let (left, right) = piece.split_at(0.5); + todo.push((right, depth + 1)); + todo.push((left, depth + 1)); + continue; + } + segs.push(piece); + from.push(i); + } + } + (Loop2 { segs }, from) +} + /// [`offset_loop`] for a loop of lines, arcs and free curves. 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 each free curve moves its control polygon ([`offset_polygon`]). Where two @@ -646,7 +839,13 @@ fn offset_segments(lp: &Loop2, d: f64) -> Result>> { // Two sides running back towards each other across a gone tip: the stroke is // thinner than the taper. (None, None) if !adjacent && turn.abs() < 1e-9 => return Err(closes()), - (None, None) if !tangent && turn * d < 0.0 => { + // Running on smoothly (the pieces of one curve): both move to the same point. + // (The tangents' crossing is ill-conditioned there.) + (None, None) if tangent => { + let m = s.start() + right(tout) * d; + (m, m) + } + (None, None) if turn * d < 0.0 => { into.push((k, i)); (prev.end() + right(tin) * d, s.start() + right(tout) * d) } @@ -663,7 +862,6 @@ fn offset_segments(lp: &Loop2, d: f64) -> Result>> { let mut pieces: Vec> = vec![None; n]; for &i in &kept { let (Some(s), Some(m), Some(&a), Some(&b)) = (lp.segs.get(i), moved.get(i), starts.get(i), ends.get(i)) else { return Err(bad()) }; - let curve = |ctrl: &[Vec2]| offset_polygon(ctrl, a, b, d).ok_or_else(closes); let piece = match (m, *s) { (Some(arc), _) => { if arc.start().dist(a) > 1e-6 || arc.end().dist(b) > 1e-6 { @@ -672,23 +870,8 @@ fn offset_segments(lp: &Loop2, d: f64) -> Result>> { *arc } (None, Seg2::Line { .. }) => Seg2::Line { a, b }, - (None, Seg2::Cubic { p0, p1, p2, p3 }) => match curve(&[p0, p1, p2, p3])?[..] { - [p0, p1, p2, p3] => Seg2::Cubic { p0, p1, p2, p3 }, - _ => return Err(bad()), - }, - (None, Seg2::Conic { a: c0, apex, b: c1, w }) => match curve(&[c0, apex, c1])?[..] { - [a, apex, b] => Seg2::Conic { a, apex, b, w }, - _ => return Err(bad()), - }, - (None, Seg2::Bezier { n: degree, p }) => { - let k = (degree as usize).clamp(1, solidcraft_geom::MAX_BEZIER_DEGREE); - let mut q = p; - for (dst, src) in q.iter_mut().zip(curve(p.get(..=k).ok_or_else(bad)?)?) { - *dst = src; - } - Seg2::Bezier { n: degree, p: q } - } (None, Seg2::Arc { .. }) => return Err(bad()), + (None, curve) => offset_curve(&curve, a, b, d).ok_or_else(closes)?, }; if let Some(slot) = pieces.get_mut(i) { *slot = Some(piece); @@ -736,9 +919,14 @@ fn offset_segments(lp: &Loop2, d: f64) -> Result>> { /// Where the end of `p` crosses the start of `q`, nearest the corner between them: the /// parameters on each and the point. On polylines of a few dozen points each, the point taken -/// half way between the two crossing pieces. +/// on a line where one is, else half way between the two crossing pieces. +/// +/// Its limit: curves are sampled at [`MEET_SAMPLES`] points, so two that cross twice within one +/// sample span (a tight curve against its neighbour) can be missed. The corner then gets the +/// mitre of the end tangents instead, and the loop that makes, if it crosses itself, is refused +/// by [`offset_crosses`] rather than built. fn meet(p: &Seg2, q: &Seg2) -> Option<(f64, f64, Vec2)> { - const SAMPLES: usize = 48; + const SAMPLES: usize = MEET_SAMPLES; let samples = |s: &Seg2| -> Vec<(f64, Vec2)> { let n = if matches!(s, Seg2::Line { .. }) { 1 } else { SAMPLES }; (0..=n).map(|k| k as f64 / n as f64).map(|t| (t, s.point_at(t))).collect() @@ -768,7 +956,13 @@ fn meet(p: &Seg2, q: &Seg2) -> Option<(f64, f64, Vec2)> { if tp <= 1e-9 || tq >= 1.0 - 1e-9 { return None; } - Some((tp, tq, (p.point_at(tp) + q.point_at(tq)) * 0.5)) + // On a line where one meets it, so a line stays on its line (its side stays flat). + let x = match (p, q) { + (Seg2::Line { .. }, _) => p.point_at(tp), + (_, Seg2::Line { .. }) => q.point_at(tq), + _ => (p.point_at(tp) + q.point_at(tq)) * 0.5, + }; + Some((tp, tq, x)) } /// `s` ending at `x` (its last control point moved there). @@ -805,13 +999,17 @@ fn with_start(s: &Seg2, x: Vec2) -> Seg2 { } } -/// Most pairs of polyline pieces [`offset_crosses`] compares before it gives up (and says no). +/// Points a free curve is sampled at by [`meet`]. +const MEET_SAMPLES: usize = 48; + +/// Most pairs of polyline pieces [`offset_crosses`] compares before it gives up. const CROSSING_BUDGET: usize = 20_000_000; /// Whether the offset loops of a region cross themselves or each other, on polylines within /// `tol` of them: the taper is too steep for the profile's details (a stroke thinner than the -/// taper, a curve tighter than it, two loops it brings together). -fn offset_crosses(loops: &[Vec>], tol: f64) -> bool { +/// taper, a curve tighter than it, two loops it brings together). `None` when there is too +/// much to compare within [`CROSSING_BUDGET`]: then the offset is not known to be sound. +fn offset_crosses(loops: &[Vec>], tol: f64) -> Option { let lines: Vec> = loops .iter() .map(|l| { @@ -841,7 +1039,7 @@ fn offset_crosses(loops: &[Vec>], tol: f64) -> bool { } work += 1; if work > CROSSING_BUDGET { - return false; + return None; } // Neighbours in one loop share an end. if k == k2 && (i.abs_diff(i2) <= 1 || i.abs_diff(i2) == len.saturating_sub(1)) { @@ -852,11 +1050,11 @@ fn offset_crosses(loops: &[Vec>], tol: f64) -> bool { } let (d1, d2, d3, d4) = (side(c, d, a), side(c, d, b), side(a, b, c), side(a, b, d)); if d1 * d2 < 0.0 && d3 * d4 < 0.0 { - return true; + return Some(true); } } } - false + Some(false) } /// The side faces of one loop of a tapered extrude through `levels` of it, bottom to top (the @@ -893,7 +1091,9 @@ fn taper_loop(levels: &[(Plane, &[Option])], numbers: impl Fn(usize) -> Ve let mut ruling = |a: &mt::Vertex, b: &mt::Vertex| rulings.entry((a.id(), b.id())).or_insert_with(|| builder::line(a, b)).clone(); let flat = |wire: mt::Wire| builder::try_attach_plane(&[wire]).map_err(|e| KernelError::Failed(format!("taper sides: {e}"))); let mut sides = Vec::new(); - for ((bv, bottom), (tv, top)) in built.iter().zip(built.iter().skip(1)) { + for (((bv, bottom), (tv, top)), (low_segs, high_segs)) in + built.iter().zip(built.iter().skip(1)).zip(levels.iter().map(|(_, s)| s).zip(levels.iter().skip(1).map(|(_, s)| s))) + { for i in 0..n { let j = (i + 1) % n; let (Some(b0), Some(b1), Some(t0), Some(t1)) = (bv.get(i), bv.get(j), tv.get(i), tv.get(j)) else { return Err(bad()) }; @@ -903,8 +1103,18 @@ fn taper_loop(levels: &[(Plane, &[Option])], numbers: impl Fn(usize) -> Ve if low.len() != high.len() { return Err(bad()); } + // A line and its moved copy run side by side: their side is a plane, kept + // one (a flat side the booleans meet exactly, however slight the taper). + let lines = matches!( + (low_segs.get(i).copied().flatten(), high_segs.get(i).copied().flatten()), + (Some(Seg2::Line { .. }), Some(Seg2::Line { .. })) + ); for (e, t) in low.iter().zip(high) { let wire: mt::Wire = vec![e.clone(), ruling(e.back(), t.back()), t.inverse(), ruling(e.front(), t.front()).inverse()].into(); + if lines { + sides.push((flat(wire)?, prov.clone())); + continue; + } let surface = mt::BSplineSurface::homotopy(e.oriented_curve().lift_up(), t.oriented_curve().lift_up()); sides.push((mt::Face::new(vec![wire], mt::Surface::NurbsSurface(mt::NurbsSurface::new(surface))), prov.clone())); } @@ -953,25 +1163,36 @@ pub fn extrude_tapered_between(plane: &Plane, region: &Region2, from: f64, to: f let (t, sign) = (taper.tan(), dir_sign.signum()); let (bottom_plane, top_plane) = (plane.offset(from * sign), plane.offset(to * sign)); let loops0 = oriented(region)?; - let ends = |h: f64| loops0.loops().map(|l| offset_loop(&l.lp, h * t)).collect::>>(); + let split: Vec<(Loop2, Vec)> = loops0.loops().map(|l| offset_splits(&l.lp, from.abs().max(to.abs()) * t)).collect(); + let ends = |h: f64| split.iter().map(|(lp, _)| offset_loop(lp, h * t)).collect::>>(); let (bottoms, tops) = (ends(from)?, ends(to)?); // Polylines within a small part of the larger offset of it. let tol = (from.abs().max(to.abs()) * t.abs() * 0.02).max(1e-6); - if t != 0.0 && (offset_crosses(&bottoms, tol) || offset_crosses(&tops, tol)) { - return Err(KernelError::Invalid( - "the taper closes the profile: at this distance its sides would cross (a stroke, a curve or a gap narrower than the taper); try a smaller taper or distance".into(), - )); + if t != 0.0 { + match (offset_crosses(&bottoms, tol), offset_crosses(&tops, tol)) { + (Some(false), Some(false)) => {} + (Some(true), _) | (_, Some(true)) => { + return Err(KernelError::Invalid( + "the taper closes the profile: at this distance its sides would cross (a stroke, a curve or a gap narrower than the taper); try a smaller taper or distance".into(), + )); + } + _ => { + return Err(KernelError::TooLarge( + "the tapered profile has too many details to check that its sides stay apart; taper fewer profiles at a time".into(), + )); + } + } } guard("tapered extrude", || { let mut sides: Vec = Vec::new(); let (mut w0, mut w1): (Vec, Vec) = (Vec::new(), Vec::new()); - for ((l, b), t) in loops0.loops().zip(&bottoms).zip(&tops) { + for (((l, (_, origin)), b), t) in loops0.loops().zip(&split).zip(&bottoms).zip(&tops) { let mut levels = vec![(bottom_plane, &b[..])]; if foot > 0.0 { levels.push((plane.offset((from + foot) * sign), &b[..])); } levels.push((top_plane, &t[..])); - let (s, below, above) = taper_loop(&levels, |i| l.numbers(i).to_vec())?; + let (s, below, above) = taper_loop(&levels, |i| origin.get(i).map(|&k| l.numbers(k).to_vec()).unwrap_or_default())?; sides.extend(s); w0.push(below); w1.push(above); diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index b871c1b1..7d6186ee 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -514,6 +514,58 @@ fn tapered_extrude_of_conics() { assert!((m.bbox.max.x - r).abs() < 1e-6 && (m.bbox.max.z - h).abs() < 1e-6, "{:?}", m.bbox); } +/// A circle drawn as four cubics (as splines and text come) tapered inward. A cubic's control +/// polygon moved in is only close to the cubic's true offset, so the solid is checked against +/// the offset's volume within a tolerance: the section at depth d of a convex smooth loop has +/// area A₀ − P·d + π·d² (Steiner), so the volume is A₀·h − P·t·h²/2 + π·t²·h³/3. +#[test] +fn tapered_extrude_of_cubics_is_close_to_the_true_offset() { + let (r, h, a) = (10.0, 12.0, 8f64.to_radians()); + let k = r * 0.552_284_749_830_793_4; + let p = |x: f64, y: f64| Vec2::new(x, y); + let disc = Loop2 { + segs: vec![ + Seg2::Cubic { p0: p(r, 0.0), p1: p(r, k), p2: p(k, r), p3: p(0.0, r) }, + Seg2::Cubic { p0: p(0.0, r), p1: p(-k, r), p2: p(-r, k), p3: p(-r, 0.0) }, + Seg2::Cubic { p0: p(-r, 0.0), p1: p(-r, -k), p2: p(-k, -r), p3: p(0.0, -r) }, + Seg2::Cubic { p0: p(0.0, -r), p1: p(k, -r), p2: p(r, -k), p3: p(r, 0.0) }, + ], + }; + let (area, perimeter) = (disc.signed_area().abs(), disc.segs.iter().map(|s| s.length()).sum::()); + let b = extrude_tapered(&Plane::XY, &Region2 { outer: disc, holes: vec![] }, h, 1.0, -a).unwrap(); + let t = a.tan(); + let v = area * h - perimeter * 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); +} + +/// An S-shaped cubic (one curve turning both ways) has no moved cubic close to its offset: it is +/// cut into pieces until each piece's moved curve stays within 2 % of the offset of the true +/// one (or 10 µm), both ways. +#[test] +fn a_tapered_s_curve_is_cut_into_pieces_that_follow_its_offset() { + let s_curve = Seg2::Cubic { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(5.0, 10.0), p2: Vec2::new(10.0, -10.0), p3: Vec2::new(15.0, 0.0) }; + let d = 0.5; + assert!(crate::build::offset_error(&s_curve, d).unwrap() > 0.05 * d, "one piece is far off"); + let lp = Loop2 { + segs: vec![ + s_curve, + Seg2::Line { a: Vec2::new(15.0, 0.0), b: Vec2::new(15.0, -20.0) }, + Seg2::Line { a: Vec2::new(15.0, -20.0), b: Vec2::new(0.0, -20.0) }, + Seg2::Line { a: Vec2::new(0.0, -20.0), b: Vec2::new(0.0, 0.0) }, + ], + }; + let (cut, from) = crate::build::offset_splits(&lp, d); + let pieces: Vec<&Seg2> = cut.segs.iter().zip(&from).filter(|(_, i)| **i == 0).map(|(s, _)| s).collect(); + assert!(pieces.len() > 1 && from.iter().filter(|i| **i > 0).count() == 3, "{} pieces", pieces.len()); + for p in pieces { + for d in [d, -d] { + let e = crate::build::offset_error(p, d).unwrap(); + assert!(e <= crate::build::offset_tolerance(d) + 1e-9, "{e}"); + } + } +} + /// A wedge whose tip is a flat line far shorter than the taper moves its sides in: the tip line /// goes, the two long sides meet, and its side is the flat triangle up to there. The solid is a /// prismatoid, its volume h/6 · (A₀ + 4·A_mid + A₁). diff --git a/docs/book/src/guide/features.md b/docs/book/src/guide/features.md index 7a82dcb2..f277b19e 100644 --- a/docs/book/src/guide/features.md +++ b/docs/book/src/guide/features.md @@ -19,9 +19,12 @@ choose **New Body** explicitly to keep an addition separate. Changing the defaul operations already saved in a design. A taper slopes the sides of any profile, sketch text included: lines, arcs and spline or conic -curves move in or out together, and a flat tip shorter than the taper closes to a point. A -taper steeper than the profile's details (a stroke, a curve or a gap narrower than it) is -refused with that reason; a smaller taper or distance builds. +curves move in or out together, and a flat tip shorter than the taper closes to a point. On +spline and conic sides the slope is approximate: the moved outline is a curve of the same kind +within 2 % of the taper's offset (or 10 µm) of the true one, a curve that turns too much for +that being cut into pieces first; where a curve bends tighter than the offset, the offset folds +and is smoothed over. A taper steeper than the profile's details (a stroke, a curve or a gap +narrower than it) is refused with that reason; a smaller taper or distance builds. **Thin Extrude** (`thin: true` on `solid.extrude`, the Thin Extrude box in its dialog) builds a wall of constant thickness along sketch curves instead of filling profiles: open chains (a single diff --git a/tests/taper/before-1014/two_tapered_bosses_filleted.json b/tests/taper/before-1014/two_tapered_bosses_filleted.json new file mode 100644 index 00000000..5d7d33eb --- /dev/null +++ b/tests/taper/before-1014/two_tapered_bosses_filleted.json @@ -0,0 +1,385 @@ +{ + "format": "solidcraft/19", + "name": "Untitled", + "units": "mm", + "params": [], + "features": [ + { + "id": 1, + "name": "Sketch1", + "suppressed": false, + "type": "sketch", + "plane": { + "type": "origin", + "name": "XY" + }, + "sketch": { + "points": [ + { + "id": "origin", + "pos": [ + 0.0, + 0.0 + ], + "fixed": true + }, + { + "id": "p1", + "pos": [ + 0.0, + 0.0 + ], + "fixed": false + }, + { + "id": "p2", + "pos": [ + 80.0, + 0.0 + ], + "fixed": false + }, + { + "id": "p3", + "pos": [ + 80.0, + 40.0 + ], + "fixed": false + }, + { + "id": "p4", + "pos": [ + 0.0, + 40.0 + ], + "fixed": false + } + ], + "curves": [ + { + "id": "l1", + "type": "line", + "a": 1, + "b": 2, + "construction": false + }, + { + "id": "l2", + "type": "line", + "a": 2, + "b": 3, + "construction": false + }, + { + "id": "l3", + "type": "line", + "a": 3, + "b": 4, + "construction": false + }, + { + "id": "l4", + "type": "line", + "a": 4, + "b": 1, + "construction": false + } + ], + "constraints": [ + { + "id": "k1", + "type": "horizontal", + "l": 0 + }, + { + "id": "k2", + "type": "horizontal", + "l": 2 + }, + { + "id": "k3", + "type": "vertical", + "l": 1 + }, + { + "id": "k4", + "type": "vertical", + "l": 3 + }, + { + "id": "k5", + "type": "coincident", + "p": 0, + "q": 1 + } + ], + "counters": { + "k": 5, + "l": 4, + "p": 4 + } + } + }, + { + "id": 2, + "name": "Extrude1", + "suppressed": false, + "param_names": [ + "d1" + ], + "type": "extrude", + "sketch": 1, + "profiles": { + "type": "all" + }, + "extent": { + "distance": "20", + "direction": "positive" + }, + "operation": "new_body" + }, + { + "id": 3, + "name": "Sketch2", + "suppressed": false, + "type": "sketch", + "plane": { + "type": "custom", + "plane": { + "origin": [ + 0.0, + 0.0, + 20.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ] + } + }, + "sketch": { + "points": [ + { + "id": "origin", + "pos": [ + 0.0, + 0.0 + ], + "fixed": true + }, + { + "id": "p1", + "pos": [ + 10.0, + 10.0 + ], + "fixed": false + }, + { + "id": "p2", + "pos": [ + 30.0, + 10.0 + ], + "fixed": false + }, + { + "id": "p3", + "pos": [ + 30.0, + 30.0 + ], + "fixed": false + }, + { + "id": "p4", + "pos": [ + 10.0, + 30.0 + ], + "fixed": false + }, + { + "id": "c1.center", + "pos": [ + 55.0, + 20.0 + ], + "fixed": false + } + ], + "curves": [ + { + "id": "l1", + "type": "line", + "a": 1, + "b": 2, + "construction": false + }, + { + "id": "l2", + "type": "line", + "a": 2, + "b": 3, + "construction": false + }, + { + "id": "l3", + "type": "line", + "a": 3, + "b": 4, + "construction": false + }, + { + "id": "l4", + "type": "line", + "a": 4, + "b": 1, + "construction": false + }, + { + "id": "c1", + "type": "circle", + "c": 5, + "r": 8.0, + "construction": false + } + ], + "constraints": [ + { + "id": "k1", + "type": "horizontal", + "l": 0 + }, + { + "id": "k2", + "type": "horizontal", + "l": 2 + }, + { + "id": "k3", + "type": "vertical", + "l": 1 + }, + { + "id": "k4", + "type": "vertical", + "l": 3 + } + ], + "counters": { + "c": 1, + "k": 4, + "l": 4, + "p": 4 + } + } + }, + { + "id": 4, + "name": "Extrude2", + "suppressed": false, + "param_names": [ + "d2", + "d3" + ], + "type": "extrude", + "sketch": 3, + "profiles": { + "type": "all" + }, + "extent": { + "distance": "10", + "direction": "positive", + "taper": "-5 deg" + }, + "operation": "join" + }, + { + "id": 5, + "name": "Fillet1", + "suppressed": false, + "param_names": [ + "d4" + ], + "edge_refs": [ + { + "dir": [ + -0.08682659386424767, + -0.08682659386424767, + 0.9924325091389671 + ], + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 80.0, + 40.0, + 30.00000000052501 + ] + } + ], + "edge_names": [ + "F4:side:l2|F4:side:l3" + ], + "type": "fillet", + "edges": [ + [ + 29.56255668237038, + 29.56255668237038, + 25.0 + ] + ], + "radius": "1" + }, + { + "id": 6, + "name": "Fillet2", + "suppressed": false, + "param_names": [ + "d5" + ], + "edge_refs": [ + { + "dir": [ + 0.0, + 0.0, + 0.0 + ], + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 80.0, + 40.0, + 30.00000000247808 + ] + } + ], + "edge_names": [ + "F4:side:c1#1|F4:top#[F4:side:c1]" + ], + "type": "fillet", + "edges": [ + [ + 62.12511336474076, + 20.0, + 30.0 + ] + ], + "radius": "0.5" + } + ], + "next_id": 7 +} \ No newline at end of file