diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index 2355a6ff..d91905de 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -1015,3 +1015,32 @@ fn fillets_on_tapered_bosses_saved_before_still_find_their_edges() { assert!((volume - 69454.8852092132).abs() < 1e-3, "{volume}"); assert_eq!(s.world_state().bodies.len(), 1); } + +/// A line of text cut into a face: the letters' curves (the font's quadratics) are read +/// exactly, and the cut is decided in 2D in one boolean, at any depth and where the text runs +/// off the face. The B-rep boolean failed on many letters at once ("no result" at 3.5 mm, and +/// off the edge), else took one letter at a time on an ever larger body (tens of seconds). +#[test] +fn a_line_of_text_is_cut_in_one_exact_boolean() { + let cut = |at: [f64; 2], depth: f64| { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[105,26]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance":40})); + let before = volume(&mut s); + run(&mut s, "sketch.create", json!({"plane":{"face":[50,13,40]}})); + run(&mut s, "sketch.text", json!({"text":"textvraiment tres long","at":at,"height":7.81})); + run(&mut s, "sketch.finish", json!({})); + let id = run(&mut s, "solid.extrude", json!({"distance":-depth,"operation":"cut"}))["feature"].as_u64().unwrap(); + let r = s.model.result(id).unwrap(); + assert!(r.error.is_none() && r.boolean_diagnostics.events.is_empty(), "{at:?} {depth}: {r:?}"); + before - volume(&mut s) + }; + // Straight walls: the material taken grows with the depth, the letters' area each mm. + let (one, deep) = (cut([3.0, 9.0], 1.0), cut([3.0, 9.0], 3.5)); + assert!(rel(deep, 3.5 * one) < 1e-3, "{one} {deep}"); + // Off the face's edge, the part of the text still over it. + let off = cut([-6.0, 20.0], 1.0); + assert!(off > 0.0 && off < one, "{off} {one}"); +} diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index b839dec4..a78e73e9 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -500,15 +500,11 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { // A tool that misses its target, or covers it whole, leaves it as it is, and the // sampled shortcut below answers that by reusing the operand itself: the very same body. // The 2D route rebuilds it instead - the same shape on new faces - which would turn an - // untouched body in a timeline into a different solid. Those pairs go back to the path - // below, which decides them, and the identity they keep, exactly as it always has. - let unchanged = 1e-4 * (va0 + vb0); - let rebuilt_from_an_operand = match op { - BoolOp::Cut => v0 >= va0 - unchanged, - BoolOp::Intersect => (v0 - va0).abs() <= unchanged || (v0 - vb0).abs() <= unchanged, - BoolOp::Union => false, - }; - if !rebuilt_from_an_operand && plausible(v0, op, va0, vb0, slack0) { + // untouched body in a timeline into a different solid. The route declines those pairs + // itself, from its exact sections (a volume measured off meshes could not tell an + // engraving of thin strokes from no change), and they take the path below, which + // decides them, and the identity they keep, exactly as it always has. + if plausible(v0, op, va0, vb0, slack0) { return Ok(Some(body.clone())); } } diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs index 17fe0539..88444214 100644 --- a/crates/kernel/src/prism.rs +++ b/crates/kernel/src/prism.rs @@ -17,6 +17,10 @@ use crate::{BoolOp, KernelError, Result}; #[path = "prism_lineage.rs"] mod lineage; +#[cfg(test)] +#[path = "prism_spline_tests.rs"] +mod spline_tests; + const MAX_PLANAR_SEGMENTS: usize = 8192; const MAX_PLANAR_EVENTS: usize = 131_072; const MAX_PLANAR_PAIRS: usize = 16_000_000; @@ -216,10 +220,13 @@ pub(crate) struct Tag { pub far: Option, pub start: Option, pub end: Option, + /// Inside [`boolean2d_tagged`] only: the free-form segment (by number) the arc this tags + /// was fitted to, so the result's pieces of it get the exact curve back. + pub free: Option, } /// A segment nothing is known of. -const BLANK: Tag = Tag { operand: 0, wall: None, edge: None, far: None, start: None, end: None }; +const BLANK: Tag = Tag { operand: 0, wall: None, edge: None, far: None, start: None, end: None, free: None }; impl Tag { /// The segment run the other way. @@ -475,6 +482,7 @@ impl Index { far: None, start: self.vertices.get(&e.front().id()).copied(), end: self.vertices.get(&e.back().id()).copied(), + free: None, } } @@ -1055,8 +1063,307 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option Option
{ + let line_arc = |s: &Section| s.regions.iter().all(Region2::is_line_arc); + if line_arc(a) && line_arc(b) { + return boolean2d_arcs(a, b, op, tol); + } + let mut sources = Vec::new(); + let (fa, fb) = (fitted(a, &mut sources, PLANAR_FIT_TOL)?, fitted(b, &mut sources, PLANAR_FIT_TOL)?); + let out = boolean2d_arcs(&fa, &fb, op, tol)?; + Some(exact_again(out, &sources, tol)) +} + +/// `s` with each free-form segment replaced by lines and arcs within `fit`, tagged with its +/// number in `sources` (where it is put). +fn fitted(s: &Section, sources: &mut Vec, fit: f64) -> Option
{ + let mut out = Section::default(); + let mut pieces = 0usize; + for (r, reg) in s.regions.iter().enumerate() { + let mut loops = Vec::new(); + let mut tags = Vec::new(); + for (l, lp) in std::iter::once(®.outer).chain(®.holes).enumerate() { + let (mut segs, mut ts) = (Vec::new(), Vec::new()); + for (i, seg) in lp.segs.iter().enumerate() { + let tag = s.tag(r, l, i); + if seg.is_line_or_arc() { + segs.push(*seg); + ts.push(tag); + continue; + } + let arcs = seg.fit_arcs(fit)?; + pieces = pieces.checked_add(arcs.len()).filter(|n| *n <= MAX_PLANAR_SEGMENTS)?; + let n = arcs.len(); + ts.extend((0..n).map(|k| Tag { free: Some(sources.len()), ..tag.piece(k, n) })); + segs.extend(arcs); + sources.push(*seg); + } + loops.push(Loop2 { segs }); + tags.push(ts); + } + let mut loops = loops.into_iter(); + out.regions.push(Region2 { outer: loops.next()?, holes: loops.collect() }); + out.tags.push(tags); + } + Some(out) +} + +/// A loop's piece in [`exact_again`]: a run of arcs fitted to free-form segment `src`, standing +/// for its exact curve from parameter `t0` to `t1` (`t1 < t0`: run backwards), from `a` to `b`; +/// or a line or arc as it is. +#[derive(Clone, Copy)] +enum Piece { + Exact { src: usize, t0: f64, t1: f64, a: Vec2, b: Vec2, tag: Tag }, + Plain(Seg2, Tag), +} + +impl Piece { + fn end(&self) -> Vec2 { + match self { + Piece::Exact { b, .. } => *b, + Piece::Plain(s, _) => s.end(), + } + } + + /// The piece ending at `at` (an exact curve: at its parameter `t`, when given). + fn set_end(&mut self, t: Option, at: Vec2) { + match self { + Piece::Exact { t1, b, .. } => { + if let Some(t) = t { + *t1 = t; + } + *b = at; + } + Piece::Plain(s, _) => *s = seg_with_ends(s, s.start(), at), + } + } + + /// The piece starting at `at` (an exact curve: at its parameter `t`, when given). + fn set_start(&mut self, t: Option, at: Vec2) { + match self { + Piece::Exact { t0, a, .. } => { + if let Some(t) = t { + *t0 = t; + } + *a = at; + } + Piece::Plain(s, _) => *s = seg_with_ends(s, at, s.end()), + } + } +} + +/// `s` running from `a` to `b` instead (points on it or a hair off it): a line between them, an +/// arc of the same circle, a free-form segment with those end control points. +fn seg_with_ends(s: &Seg2, a: Vec2, b: Vec2) -> Seg2 { + match *s { + Seg2::Line { .. } => Seg2::Line { a, b }, + Seg2::Arc { center, radius, start, sweep } => { + let new_start = (a - center).angle(); + let end = start + sweep; + // The same way round, through the same part of the circle. + let shift = |x: f64, near: f64| x + ((near - x) / std::f64::consts::TAU).round() * std::f64::consts::TAU; + let s0 = shift(new_start, start); + let s1 = shift((b - center).angle(), end); + Seg2::Arc { center, radius, start: s0, sweep: s1 - s0 } + } + Seg2::Cubic { p1, p2, .. } => Seg2::Cubic { p0: a, p1, p2, p3: b }, + Seg2::Conic { apex, w, .. } => Seg2::Conic { a, apex, b, w }, + Seg2::Bezier { n, mut p } => { + if let Some(x) = p.first_mut() { + *x = a; + } + if let Some(x) = p.get_mut(n as usize) { + *x = b; + } + Seg2::Bezier { n, p } + } + } +} + +/// The exact piece of `src` from parameter `t0` to `t1` (either way round), from `a` to `b`. +fn trimmed(src: &Seg2, t0: f64, t1: f64, a: Vec2, b: Vec2) -> Seg2 { + let (lo, hi) = (t0.min(t1), t0.max(t1)); + let mut s = *src; + if lo > 1e-12 { + s = s.split_at(lo).1; + } + if hi < 1.0 - 1e-12 { + // Splitting a conic renormalises its weights, which moves its parameters: the second + // split is found on the piece. + let at = s.closest_param(src.point_at(hi)); + if at < 1.0 - 1e-12 { + s = s.split_at(at).0; + } + } + if t0 <= t1 { seg_with_ends(&s, a, b) } else { seg_with_ends(&s.reversed(), a, b) } +} + +/// Where curve `p` near its parameter `t` meets `q` near its parameter `u`, by Newton's method: +/// their parameters there, or `None` when the crossing doesn't settle within `near` of `p(t)`. +fn meet(p: &Seg2, t: f64, q: &Seg2, u: f64, near: f64) -> Option<(f64, f64)> { + let (mut t, mut u) = (t, u); + let start = p.point_at(t); + let scale = 1.0 + start.x.abs().max(start.y.abs()); + for _ in 0..50 { + let f = p.point_at(t) - q.point_at(u); + if f.len() < 1e-14 * scale { + break; + } + // P(t) - Q(u) = f; the step solves [P'(t), -Q'(u)] (dt, du) = -f. + let (dp, dq) = (p.derivative(t), q.derivative(u)); + let det = -dp.cross(dq); + if !(det.abs() > 1e-300 && det.is_finite()) { + return None; + } + t = (t + f.cross(dq) / det).clamp(0.0, 1.0); + u = (u + f.cross(dp) / det).clamp(0.0, 1.0); + } + let (x, y) = (p.point_at(t), q.point_at(u)); + (x.dist(y) < 1e-9 * scale && x.dist(start) < near).then_some((t, u)) +} + +/// The whole line or circle a line or arc lies on (as a segment reaching past it), what an exact +/// crossing with a curve is looked for on. +fn carrier(s: &Seg2) -> Option { + match *s { + Seg2::Line { a, b } => Some(Seg2::Line { a: a - (b - a), b: b + (b - a) }), + Seg2::Arc { center, radius, start, sweep } => { + let turn = sweep.signum() * std::f64::consts::PI; + Some(Seg2::Arc { center, radius, start: start + sweep * 0.5 - turn, sweep: 2.0 * turn }) + } + _ => None, + } +} + +/// The exact curves back in the result of a boolean of [`fitted`] sections: each run of arcs +/// fitted to one free-form segment becomes that segment's exact piece, and where a run ends on +/// another segment (a cut across the curve) both end at their exact crossing. Where that +/// crossing can't be found they end where the arrangement put them, within +/// [`PLANAR_FIT_TOL`] of it. +fn exact_again(out: Section, sources: &[Seg2], tol: f64) -> Section { + let near = (PLANAR_FIT_TOL + tol) * 10.0; + let mut res = Section::default(); + for (r, reg) in out.regions.iter().enumerate() { + let tags = out.region_tags(r); + let mut loops = Vec::new(); + let mut loop_tags = Vec::new(); + for (l, lp) in std::iter::once(®.outer).chain(®.holes).enumerate() { + let (segs, ts) = exact_loop(lp, tags.get(l).map(Vec::as_slice).unwrap_or(&[]), sources, near); + loops.push(Loop2 { segs }); + loop_tags.push(ts); + } + let mut loops = loops.into_iter(); + let Some(outer) = loops.next() else { continue }; + res.regions.push(Region2 { outer, holes: loops.collect() }); + res.tags.push(loop_tags); + } + res +} + +/// One loop of [`exact_again`]: its segments and their tags. +fn exact_loop(lp: &Loop2, tags: &[Tag], sources: &[Seg2], near: f64) -> (Vec, Vec) { + let n = lp.segs.len(); + let tag = |i: usize| Tag { free: None, ..tags.get(i).copied().unwrap_or(BLANK) }; + let free = |i: usize| tags.get(i).and_then(|t| t.free); + let plain = || (lp.segs.clone(), (0..n).map(tag).collect::>()); + // Start at the first segment of a run (a loop that is one run stays as it is). + let Some(first) = (0..n).find(|&i| free(i).is_none() || free(i) != free((i + n - 1) % n)) else { return plain() }; + let mut pieces: Vec = Vec::new(); + let mut k = 0; + while k < n { + let i = (first + k) % n; + let Some(seg) = lp.segs.get(i) else { return plain() }; + let Some((f, src)) = free(i).and_then(|f| sources.get(f).map(|s| (f, s))) else { + pieces.push(Piece::Plain(*seg, tag(i))); + k += 1; + continue; + }; + let mut j = k; + while j + 1 < n && free((first + j + 1) % n) == Some(f) { + j += 1; + } + let Some(last) = lp.segs.get((first + j) % n) else { return plain() }; + let at = |p: Vec2| { + if p.dist(src.start()) < 1e-9 { + 0.0 + } else if p.dist(src.end()) < 1e-9 { + 1.0 + } else { + src.closest_param(p) + } + }; + let (t0, t1) = (at(seg.start()), at(last.end())); + if (t1 - t0).abs() < 1e-9 { + // A sliver of the curve: its arcs as they are. + for m in k..=j { + let q = (first + m) % n; + pieces.push(Piece::Plain(*lp.segs.get(q).unwrap_or(seg), tag(q))); + } + } else { + let t = Tag { end: tag((first + j) % n).end, ..tag(i) }; + pieces.push(Piece::Exact { src: f, t0, t1, a: src.point_at(t0), b: src.point_at(t1), tag: t }); + } + k = j + 1; + } + // Each joint at an exact curve: both sides end at the same point, the exact crossing where + // the curve is cut, its own end point where it ends. + let m = pieces.len(); + let inner = |t: f64| t > 1e-9 && t < 1.0 - 1e-9; + for i in 0..m { + let (Some(p), Some(q)) = (pieces.get(i).copied(), pieces.get((i + 1) % m).copied()) else { continue }; + let joint = p.end(); + let src = |f: usize| sources.get(f); + let found = match (p, q) { + (Piece::Exact { src: f, t1, .. }, Piece::Exact { src: g, t0, .. }) if f != g && (inner(t1) || inner(t0)) => { + src(f).zip(src(g)).and_then(|(x, y)| meet(x, t1, y, t0, near).map(|(a, b)| (Some(a), Some(b), x.point_at(a)))) + } + (Piece::Exact { src: f, t1, .. }, Piece::Plain(s, _)) if inner(t1) => { + src(f).zip(carrier(&s)).and_then(|(x, c)| meet(x, t1, &c, c.closest_param(joint), near).map(|(a, _)| (Some(a), None, x.point_at(a)))) + } + (Piece::Plain(s, _), Piece::Exact { src: f, t0, .. }) if inner(t0) => { + src(f).zip(carrier(&s)).and_then(|(x, c)| meet(x, t0, &c, c.closest_param(joint), near).map(|(a, _)| (None, Some(a), x.point_at(a)))) + } + _ => None, + }; + // No crossing to refine (or none found): both end where the curve does. + let found = found.or(match (p, q) { + (Piece::Exact { b, .. }, _) => Some((None, None, b)), + (_, Piece::Exact { a, .. }) => Some((None, None, a)), + _ => None, + }); + let Some((x, y, at)) = found else { continue }; + if let Some(p) = pieces.get_mut(i) { + p.set_end(x, at); + } + if let Some(q) = pieces.get_mut((i + 1) % m) { + q.set_start(y, at); + } + } + let mut segs = Vec::with_capacity(m); + let mut ts = Vec::with_capacity(m); + for p in pieces { + match p { + Piece::Exact { src, t0, t1, a, b, tag } => match sources.get(src) { + Some(s) => { + segs.push(trimmed(s, t0, t1, a, b)); + ts.push(tag); + } + None => return plain(), + }, + Piece::Plain(s, t) => { + segs.push(s); + ts.push(t); + } + } + } + (segs, ts) +} + +/// The boolean of sections of lines and arcs alone ([`boolean2d_tagged`]). +fn boolean2d_arcs(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option
{ let count = |rs: &[Region2]| rs.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).fold(0usize, |n, lp| n.saturating_add(lp.segs.len())); let (na, nb) = (count(&a.regions), count(&b.regions)); @@ -1347,9 +1654,6 @@ fn simple_loops(lp: Loop2, tags: Vec, near: f64) -> Vec<(Loop2, Vec)> } fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option> { - if !matches!(seg, Seg2::Line { .. } | Seg2::Arc { .. }) { - return None; - } let mut ts: Vec = pts .iter() .filter_map(|p| { @@ -1372,7 +1676,7 @@ fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option> { out.push(match *seg { Seg2::Line { .. } => Seg2::Line { a: seg.point_at(t0), b: seg.point_at(t1) }, Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center, radius, start: start + sweep * t0, sweep: sweep * (t1 - t0) }, - _ => return None, + _ => trimmed(seg, t0, t1, seg.point_at(t0), seg.point_at(t1)), }); t0 = t1; } @@ -1510,6 +1814,23 @@ fn wall_key(s: &Seg2) -> Option<[u64; 6]> { match s { Seg2::Line { a, b } => Some([0, bits(a.x)?, bits(a.y)?, bits(b.x)?, bits(b.y)?, 0]), Seg2::Arc { center, radius, start, sweep } => Some([1, bits(center.x)?, bits(center.y)?, bits(*radius)?, bits(*start)?, bits(*sweep)?]), + // Its ends and a point between them: free-form segments with the same ones are the same + // curve as far as a wall over it goes. + _ => { + let (a, m, b) = (s.start(), s.point_at(0.5), s.end()); + let h = |x: f64, y: f64| bits(x).zip(bits(y)).map(|(x, y)| x ^ y.rotate_left(17)); + Some([2, h(a.x, a.y)?, h(m.x, m.y)?, h(b.x, b.y)?, bits(m.x)?, bits(m.y)?]) + } + } +} + +/// A free-form segment as the control points of the cubic Bézier it is (a quadratic raised to +/// a cubic); `None` for those no cubic is exactly (rational conics, higher degrees). +fn cubic_of(s: &Seg2) -> Option<[Vec2; 4]> { + match *s { + Seg2::Cubic { p0, p1, p2, p3 } => Some([p0, p1, p2, p3]), + Seg2::Conic { a, apex, b, w } if (w - 1.0).abs() < 1e-12 => Some([a, a + (apex - a) * (2.0 / 3.0), b + (apex - b) * (2.0 / 3.0), b]), + Seg2::Bezier { n: 3, p } => Some([p[0], p[1], p[2], p[3]]), _ => None, } } @@ -1551,12 +1872,47 @@ fn sew_connected_sections( pts.push(sg.end()); } } - let at = |u: Vec2, z: f64| plane.to_world(u) + d * z; - let edge = |sg: &Seg2, z: f64| -> EdgeSpec { - match sg { - Seg2::Arc { .. } => EdgeSpec::Arc { a: at(sg.start(), z), b: at(sg.end(), z), mid: at(sg.point_at(0.5), z) }, - _ => EdgeSpec::Line { a: at(sg.start(), z), b: at(sg.end(), z) }, + // Ends within `tol` of each other are one point. The 2D boolean closes its loops within + // `tol`, not exactly: where a font's nearly round curve was read as an arc, the arc ends a + // few hundredths of a micron from the curve next to it. Sewing joins only ends at the same + // point, so such a loop would not close. + let cell = |u: Vec2| ((u.x / tol).floor() as i64, (u.y / tol).floor() as i64); + let mut ends: Vec = Vec::new(); + let mut grid: std::collections::HashMap<(i64, i64), Vec> = std::collections::HashMap::new(); + let weld = |ends: &[Vec2], grid: &std::collections::HashMap<(i64, i64), Vec>, u: Vec2| -> Option { + let (cx, cy) = cell(u); + (cx - 1..=cx + 1) + .flat_map(|x| (cy - 1..=cy + 1).map(move |y| (x, y))) + .filter_map(|c| grid.get(&c)) + .flatten() + .filter_map(|&i| ends.get(i).copied()) + .find(|e| e.dist(u) <= tol) + }; + for &u in &pts { + if weld(&ends, &grid, u).is_none() { + grid.entry(cell(u)).or_default().push(ends.len()); + ends.push(u); } + } + // A piece of a free-form curve whose ends weld into one point (a font's curve cut a hair from + // its end) is no edge and has no wall: the pieces on either side meet there. (Lines and arcs + // are left as they were: such a piece of one fails the sewing, as before.) + let short = |s: &Seg2| { + !s.is_line_or_arc() + && s.length() <= 2.0 * tol + && matches!((weld(&ends, &grid, s.start()), weld(&ends, &grid, s.end())), (Some(p), Some(q)) if p == q) + }; + let at = |u: Vec2, z: f64| plane.to_world(u) + d * z; + let welded = |u: Vec2, z: f64| at(weld(&ends, &grid, u).unwrap_or(u), z); + let edge = |sg: &Seg2, z: f64| -> Option { + Some(match sg { + Seg2::Line { .. } => EdgeSpec::Line { a: welded(sg.start(), z), b: welded(sg.end(), z) }, + Seg2::Arc { .. } => EdgeSpec::Arc { a: welded(sg.start(), z), b: welded(sg.end(), z), mid: at(sg.point_at(0.5), z) }, + _ => { + let [p0, p1, p2, p3] = cubic_of(sg)?; + EdgeSpec::Chain { ctrl: vec![welded(p0, z), at(p1, z), at(p2, z), welded(p3, z)].into() } + } + }) }; // Exact oriented generated segments alone define a run. No support fitting, // proximity matching, or old face lineage is used here. @@ -1574,6 +1930,9 @@ fn sew_connected_sections( let pieces = split_seg(sg0, &pts, tol)?; let (n, tag) = (pieces.len(), slab.tag(r, l, si)); for (k, sg) in pieces.into_iter().enumerate() { + if short(&sg) { + continue; + } remaining = remaining.checked_sub(1)?; let key = wall_key(&sg)?; let prior = if coalesce { last.get(&key).copied() } else { None }; @@ -1622,20 +1981,22 @@ fn sew_connected_sections( let (z0, z1) = (levels[first], levels[end]); let (a, b) = (sg.start(), sg.end()); let mut lp3 = Vec::with_capacity(2 + 2 * (end - first)); - lp3.push(edge(&sg, z0)); + lp3.push(edge(&sg, z0)?); // Keep station vertices at both ends: neighboring stepped walls/caps // must share complete edges rather than create T-junctions. for k in first..end { - lp3.push(EdgeSpec::Line { a: at(b, levels[k]), b: at(b, levels[k + 1]) }); + lp3.push(EdgeSpec::Line { a: welded(b, levels[k]), b: welded(b, levels[k + 1]) }); } - lp3.push(edge(&sg.reversed(), z1)); + lp3.push(edge(&sg.reversed(), z1)?); for k in (first..end).rev() { - lp3.push(EdgeSpec::Line { a: at(a, levels[k + 1]), b: at(a, levels[k]) }); + lp3.push(EdgeSpec::Line { a: welded(a, levels[k + 1]), b: welded(a, levels[k]) }); } let outward = plane.dir_to_world(sg.derivative(0.5)).cross(d).normalized()?; let surface = match sg { + Seg2::Line { .. } => SurfSpec::Plane, Seg2::Arc { .. } => SurfSpec::Extruded { a: at(a, z0), b: at(b, z0), mid: at(sg.point_at(0.5), z0), dir: d * (z1 - z0) }, - _ => SurfSpec::Plane, + // A free-form wall: the lines along `d` joining the curve at both levels. + _ => SurfSpec::Ruled { a: edge(&sg, z0)?, b: edge(&sg, z1)? }, }; faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) }); // Axis-aligned generated lines have a common oriented support frame @@ -1668,10 +2029,10 @@ fn sew_connected_sections( for lp in std::iter::once(&r.outer).chain(&r.holes) { let mut segs: Vec = Vec::new(); for sg in &lp.segs { - segs.extend(split_seg(sg, &pts, tol)?); + segs.extend(split_seg(sg, &pts, tol)?.into_iter().filter(|s| !short(s))); } let segs: Vec = if *up { segs } else { segs.iter().rev().map(Seg2::reversed).collect() }; - loops.push(segs.iter().map(|sg| edge(sg, *z)).collect::>()); + loops.push(segs.iter().map(|sg| edge(sg, *z)).collect::>>()?); } faces.push(FaceSpec { loops, surface: SurfSpec::Plane, probe: None }); sources.push(None); @@ -1700,24 +2061,9 @@ fn simple_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option Option>> { let size = a.size().max(b.size()); let tol = (size * 1e-7).max(1e-9) * 10.0; - // Candidate directions: a's planar face normals (each line once). - let mut dirs: Vec = Vec::new(); - let mut remaining_direction_faces = MAX_PLANAR_SEGMENTS * 2; - for f in a.solid.face_iter() { - remaining_direction_faces = remaining_direction_faces.checked_sub(1)?; - if let mt::Surface::Plane(p) = f.oriented_surface() { - let n = p.normal(); - if let Some(n) = Vec3::new(n.x, n.y, n.z).normalized() - && !dirs.iter().any(|m| m.dot(n).abs() > 1.0 - 1e-9) - { - dirs.push(if n.dot(Vec3::new(1e-3, 1e-6, 1.0)) < 0.0 { -n } else { n }); - if dirs.len() == 8 { - break; - } - } - } - } - let (d, plane, pa, pb) = dirs.into_iter().take(8).find_map(|d| { + // Candidate directions (each line once, pointing up). + let dirs: Vec = directions(a, b)?.into_iter().map(|n| if n.dot(Vec3::new(1e-3, 1e-6, 1.0)) < 0.0 { -n } else { n }).collect(); + let (d, plane, pa, pb) = dirs.into_iter().find_map(|d| { let plane = Plane::from_normal(Vec3::ZERO, d)?; let pa = prism_tagged(a, d, &plane, tol, ia)?; let pb = prism_tagged(b, d, &plane, tol, ib)?; @@ -1743,10 +2089,16 @@ fn simple_prism_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2 if regions.is_empty() { return Some(Ok(None)); } + let (u, x, y) = (area(regions), area(&pa.section.regions), area(&pb.section.regions)); + let alike = |v: f64| (u - v).abs() <= tol * (1.0 + u); + // A tool that misses its target (or an intersection that is one operand whole) leaves it + // as it is: the B-rep boolean reuses that operand itself, where this would rebuild it. + if matches!(op, BoolOp::Cut | BoolOp::Intersect) && (same_area(u, x, tol) || (op == BoolOp::Intersect && same_area(u, y, tol))) { + return None; + } let (lo, hi) = if stacked { // Different sections (a boss hanging over a plate's edge): sewn slab by slab. - let (u, x, y) = (area(regions), area(&pa.section.regions), area(&pb.section.regions)); - if (u - x).abs() > tol * (1.0 + u) || (u - y).abs() > tol * (1.0 + u) { + if !alike(x) || !alike(y) { return sew_stack(&plane, d, &pa, &pb, tol, &history).map(|r| r.map(Some)); } // One outline end to end: also slab by slab, so the history follows each operand's @@ -1784,6 +2136,46 @@ fn simple_prism_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2 Some(Ok(Some(history.attach(last.0.clone(), &[last])))) } +/// The directions to try the operands as prisms along: the first eight plane directions of the +/// target (in its face order), then those of the tool's caps (a pair of opposite plane faces) +/// that the target has further on. A target cut many times over (a line of text, a group of +/// letters at a time) has more plane directions than eight, its walls' among them: in face +/// order its caps' direction can come too late. `None` past the face budget. +fn directions(a: &Body, b: &Body) -> Option> { + if a.face_count().saturating_add(b.face_count()) > MAX_PLANAR_SEGMENTS * 2 { + return None; + } + let normals = |x: &Body| -> Vec { + x.solid + .face_iter() + .filter_map(|f| match f.oriented_surface() { + mt::Surface::Plane(p) => { + let n = p.normal(); + Vec3::new(n.x, n.y, n.z).normalized() + } + _ => None, + }) + .collect() + }; + let mut target: Vec = Vec::new(); + for n in normals(a) { + if !target.iter().any(|m| m.dot(n).abs() > 1.0 - 1e-9) { + target.push(n); + } + } + let tool = normals(b); + let capped = |d: &Vec3| tool.iter().any(|n| n.dot(*d) > 1.0 - 1e-9) && tool.iter().any(|n| n.dot(*d) < -1.0 + 1e-9); + let mut dirs: Vec = target.iter().take(8).copied().collect(); + dirs.extend(target.iter().skip(8).filter(|d| capped(d)).take(8)); + Some(dirs) +} + +/// Whether a boolean of sections left one of area `v` as it was (area `u` after): the same to +/// within rounding, as the very same outline gives (a change is at least a sliver `tol` wide). +fn same_area(u: f64, v: f64, tol: f64) -> bool { + (u - v).abs() <= 1e-9 * u.abs().max(v.abs()) + tol * tol +} + /// A stepped extrusion (case floor plus walls), recovered from its cap events. /// Downward caps add a section above their level; upward caps remove one. #[cfg(test)] @@ -1886,19 +2278,9 @@ fn stack_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2], comm ps.iter().find(|p| p.lo <= lo + tol && p.hi >= hi - tol).map(|p| p.section.clone()).unwrap_or_default() } let tol = (a.size().max(b.size()) * 1e-7).max(1e-9) * 10.0; - let mut dirs: Vec = Vec::new(); - for f in a.solid.face_iter() { - if let mt::Surface::Plane(p) = f.oriented_surface() { - let n = p.normal(); - if let Some(n) = Vec3::new(n.x, n.y, n.z).normalized() - && !dirs.iter().any(|m| m.dot(n).abs() > 1.0 - 1e-9) - { - dirs.push(n); - } - } - } + let dirs = directions(a, b)?; let ends = |ps: &[Prism]| ps.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.lo), hi.max(p.hi))); - let (d, plane, pa, pb) = dirs.into_iter().take(8).find_map(|d| { + let (d, plane, pa, pb) = dirs.into_iter().find_map(|d| { let plane = Plane::from_normal(Vec3::ZERO, d)?; let pa = stack_tagged(a, d, &plane, tol, ia)?; let pb = stack_tagged(b, d, &plane, tol, ib)?; @@ -1919,10 +2301,15 @@ fn stack_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2], comm let mut slabs = Vec::new(); let mut work = 0usize; let mut output_edges = 0usize; + let area = + |rs: &[Region2]| rs.iter().map(|r| r.outer.signed_area().abs() - r.holes.iter().map(|h| h.signed_area().abs()).sum::()).sum::(); + // Whether some slab's section came out other than the target's (else the tool missed it). + let mut changed = false; let edges = |regions: &[Region2]| regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).map(|l| l.segs.len()).sum::(); for w in levels.windows(2) { let (lo, hi) = (*w.first()?, *w.get(1)?); let (x, y) = (section(&pa, lo, hi, tol), section(&pb, lo, hi, tol)); + let before = area(&x.regions); if op == BoolOp::Cut { let count = edges(&x.regions).saturating_add(edges(&y.regions)); work = work.saturating_add(count.saturating_mul(count)); @@ -1936,6 +2323,7 @@ fn stack_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2], comm (_, true, _) => x, _ => boolean2d_tagged(&x, &y, op, tol)?, }; + changed |= !same_area(area(&s.regions), before, tol); if op == BoolOp::Cut { output_edges = output_edges.saturating_add(edges(&s.regions)); if output_edges > 8192 { @@ -1947,6 +2335,11 @@ fn stack_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2], comm if slabs.iter().all(|s| s.regions.is_empty()) { return Some(Ok(None)); } + // A cut that takes nothing leaves the target as it is: the B-rep boolean reuses it itself, + // where this would rebuild it. + if op == BoolOp::Cut && !changed { + return None; + } let history = lineage::Context::new(op, [a, b], [ia.get(), ib.get()], plane, tol, None); let coalesce = op == BoolOp::Cut && !a.regularization_protected diff --git a/crates/kernel/src/prism_curve.rs b/crates/kernel/src/prism_curve.rs index cada5e37..27ec3b76 100644 --- a/crates/kernel/src/prism_curve.rs +++ b/crates/kernel/src/prism_curve.rs @@ -276,6 +276,60 @@ pub(super) fn segment(e: &mt::Edge, plane: &Plane, tol: f64, work: &mut usize) - if a.dist(b) > tol && spans.iter().all(|s| line_span(s, a, b, tol, error, work).is_some()) { return Some(vec![Seg2::Line { a, b }]); } + arc(&c, &spans, plane, a, b, tol, error, work).or_else(|| free_form(&spans, a, b, tol)) +} + +/// One Bézier span that is neither a line nor an arc, as the exact free-form segment it is +/// (a font's quadratic or cubic, a conic), its ends `a` and `b`. `None` for several spans (a +/// curve that may wind about more than once), degrees past [`solidcraft_geom::MAX_BEZIER_DEGREE`], +/// rational spans other than conics, and control polygons with no area (a curve doubling back +/// along a line). +fn free_form(spans: &[Vec], a: Vec2, b: Vec2, tol: f64) -> Option> { + let [span] = spans else { return None }; + let pts: Vec = span.iter().map(|p| point(*p)).collect(); + let (first, last) = (*pts.first()?, *pts.last()?); + if first.dist(a) > tol || last.dist(b) > tol || first.dist(last) <= tol { + return None; + } + // Twice the area the control polygon spans off the chord: none is a line, maybe doubling back. + let chord = last - first; + let off = pts.iter().map(|p| chord.cross(*p - first).abs()).fold(0., f64::max) / chord.len(); + if !(off > tol) { + return None; + } + let rational = span.iter().any(|p| (p[2] - 1.).abs() > 1e-12); + let seg = match (pts.len(), rational) { + (_, false) => solidcraft_geom::bezier(&pts)?, + (3, true) => { + // Weights normalised to 1 at the ends: the middle one is the conic's. + let (w0, w1, w2) = (span[0][2], span[1][2], span[2][2]); + let w = w1 / (w0 * w2).sqrt(); + if !(w.is_finite() && w > 1e-9) { + return None; + } + Seg2::Conic { a: first, apex: pts[1], b: last, w } + } + _ => return None, + }; + // The exact ends of the edge (the span's agree within the tolerance). + let seg = match seg { + Seg2::Cubic { p1, p2, .. } => Seg2::Cubic { p0: a, p1, p2, p3: b }, + Seg2::Conic { apex, w, .. } => Seg2::Conic { a, apex, b, w }, + Seg2::Bezier { n, mut p } => { + *p.first_mut()? = a; + *p.get_mut(n as usize)? = b; + Seg2::Bezier { n, p } + } + other => other, + }; + Some(vec![seg]) +} + +/// The spline `c` (its Bézier `spans` in the plane, from `a` to `b`) as a circular arc, when +/// whole-span proofs show it is one. +#[allow(clippy::too_many_arguments)] +fn arc(c: &mt::Curve, spans: &[Vec], plane: &Plane, a: Vec2, b: Vec2, tol: f64, error: f64, work: &mut usize) -> Option> { + let (t0, t1) = c.range_tuple(); // Samples are only a candidate proposal. Whole-span implicit and oriented // angular proofs below are required before converting any spline to an arc. let q = plane.to_local(from_p3(c.subs(t0 + (t1 - t0) * 5. / 16.))) - a; @@ -290,7 +344,7 @@ pub(super) fn segment(e: &mt::Edge, plane: &Plane, tol: f64, work: &mut usize) - return None; } let mut sweep: f64 = 0.; - for span in &spans { + for span in spans { let ds = circle_span(span, center, radius, tol, error, 0, work)?; if sweep != 0. && sweep.signum() != ds.signum() { return None; diff --git a/crates/kernel/src/prism_spline_tests.rs b/crates/kernel/src/prism_spline_tests.rs new file mode 100644 index 00000000..6fe0b48a --- /dev/null +++ b/crates/kernel/src/prism_spline_tests.rs @@ -0,0 +1,87 @@ +use super::*; + +/// A rounded square of four quadratic curves (a font's kind) about (20, 15). +fn rounded() -> Region2 { + let p = Vec2::new; + let q = |a: Vec2, apex: Vec2, b: Vec2| Seg2::Conic { a, apex, b, w: 1.0 }; + let segs = vec![ + q(p(10.0, 15.0), p(10.0, 5.0), p(20.0, 5.0)), + q(p(20.0, 5.0), p(30.0, 5.0), p(30.0, 15.0)), + q(p(30.0, 15.0), p(30.0, 25.0), p(20.0, 25.0)), + q(p(20.0, 25.0), p(10.0, 25.0), p(10.0, 15.0)), + ]; + Region2 { outer: Loop2 { segs }, holes: vec![] } +} + +fn block() -> Body { + crate::box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(40.0, 30.0, 10.0)).unwrap() +} + +/// Its free-form faces (the tool's walls kept as the curves they were). +fn spline_faces(b: &Body) -> usize { + b.solid.face_iter().filter(|f| matches!(f.surface(), mt::Surface::BSplineSurface(_) | mt::Surface::NurbsSurface(_))).count() +} + +/// A shape of a font's curves engraved into a block is decided in 2D, its walls the exact curves. +#[test] +fn an_engraving_of_quadratic_curves_is_exact_in_2d() { + let region = rounded(); + let area = region.outer.signed_area().abs(); + let tool = crate::extrude(&Plane::XY, std::slice::from_ref(®ion), 9.0, 11.0).unwrap().remove(0); + let out = prism_boolean(&block(), &tool, BoolOp::Cut).expect("the 2D route takes it").unwrap().unwrap(); + assert!(out.validity().is_empty(), "{:?}", out.validity()); + // Half the tool, measured as the result is (meshes of curved faces are off by about 1e-4). + let taken = 12000.0 - crate::measure(&out).unwrap().volume; + let half = crate::measure(&tool).unwrap().volume / 2.0; + assert!((taken - half).abs() < 1e-6 * half && (taken - area).abs() < 2e-4 * area, "{taken} vs {half} ({area})"); + assert_eq!(spline_faces(&out), 4); +} + +/// A shape running off the block's side is cut where its curves exactly cross the side. +#[test] +fn an_engraving_running_off_the_side_ends_on_its_exact_crossings() { + let region = rounded(); + // Moved 14 to the left: the block's side x = 0 cuts its two left curves. + let shift = |s: &Seg2| match *s { + Seg2::Conic { a, apex, b, w } => { + Seg2::Conic { a: a - Vec2::new(14.0, 0.0), apex: apex - Vec2::new(14.0, 0.0), b: b - Vec2::new(14.0, 0.0), w } + } + other => other, + }; + let moved = Region2 { outer: Loop2 { segs: region.outer.segs.iter().map(shift).collect() }, holes: vec![] }; + let tool = crate::extrude(&Plane::XY, std::slice::from_ref(&moved), 9.0, 11.0).unwrap().remove(0); + let out = prism_boolean(&block(), &tool, BoolOp::Cut).expect("the 2D route takes it").unwrap().unwrap(); + assert!(out.validity().is_empty(), "{:?}", out.validity()); + // What the block keeps of the shape: the shape's part at x > 0. + let inside = planar_regions_boolean( + std::slice::from_ref(&moved), + &[Region2 { + outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0), Vec2::new(40.0, 30.0), Vec2::new(0.0, 30.0)]), + holes: vec![], + }], + BoolOp::Intersect, + 1e-6, + ) + .unwrap(); + let area: f64 = inside.iter().map(|r| r.outer.signed_area().abs()).sum(); + let taken = 12000.0 - crate::measure(&out).unwrap().volume; + assert!((taken - area).abs() < 2e-4 * area, "{taken} vs {area}"); + assert!(spline_faces(&out) >= 2, "{}", spline_faces(&out)); +} + +/// A tool that misses its target leaves it as it is: the 2D route declines it (the B-rep +/// boolean then reuses the target itself). An engraving taking far less than a ten-thousandth +/// of a large part is a change all the same, and the route keeps it: told from the exact +/// sections, not from volumes measured off meshes, which could not tell it from no change. +#[test] +fn a_tool_that_misses_is_declined_and_a_fine_engraving_is_not() { + let big = crate::box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(200.0, 200.0, 10.0)).unwrap(); + let region = rounded(); + let tool = |lo: f64, hi: f64| crate::extrude(&Plane::XY, std::slice::from_ref(®ion), lo, hi).unwrap().remove(0); + assert!(prism_boolean(&big, &tool(10.0, 12.0), BoolOp::Cut).is_none(), "a tool above the part misses it"); + // 0.05 mm deep: 17 mm³ of a 400000 mm³ part. + let out = prism_boolean(&big, &tool(9.95, 11.0), BoolOp::Cut).expect("an engraving is a change").unwrap().unwrap(); + let taken = 400_000.0 - crate::measure(&out).unwrap().volume; + let want = region.outer.signed_area().abs() * 0.05; + assert!((taken - want).abs() < 2e-3 * want, "{taken} vs {want}"); +} diff --git a/crates/kernel/src/sew.rs b/crates/kernel/src/sew.rs index 441e9543..0c5c8b4a 100644 --- a/crates/kernel/src/sew.rs +++ b/crates/kernel/src/sew.rs @@ -80,8 +80,17 @@ impl EdgeSpec { EdgeSpec::Line { .. } => None, EdgeSpec::Arc { mid, .. } => Some(key(*mid)), EdgeSpec::Chain { ctrl } => { - let n = ctrl.len().max(1) as f64; - Some(key(ctrl.iter().fold(Vec3::ZERO, |s, p| s + *p) * (1.0 / n))) + // Summed in pairs from both ends, so that the chain run either way gives the very + // same point: a font's control points are simple fractions, whose mean can fall + // exactly halfway between two steps of the key, where summing in another order + // would round it to the other one. + let n = ctrl.len(); + let sum = (0..n.div_ceil(2)).fold(Vec3::ZERO, |s, i| match (ctrl.get(i), ctrl.get(n - 1 - i)) { + (Some(p), Some(q)) if i != n - 1 - i => s + (*p + *q), + (Some(p), _) => s + *p, + _ => s, + }); + Some(key(sum * (1.0 / n.max(1) as f64))) } } } @@ -952,6 +961,23 @@ mod tests { vec![quad(a, d, c, b), quad(e, f, g, h), quad(a, b, f, e), quad(b, c, g, f), quad(c, d, h, g), quad(d, a, e, h)] } + /// A chain's key is the same whichever way it runs. These control points (a joining stroke + /// of a script font) have their mean exactly halfway between two steps of the key, and + /// summed in order one way and the other they rounded to different steps: a wall and the cap + /// beside it, reading the curve opposite ways, got two edges and the solid did not close. + #[test] + fn a_chain_has_one_key_both_ways() { + let p = |x: f64, y: f64| Vec3::new(x, y, 40.0); + let ctrl = [ + p(34.74439583333333, 11.397236111111111), + p(35.066196759259256, 11.722652777777778), + p(35.32833796296296, 11.885361111111111), + p(35.53081944444444, 11.885361111111111), + ]; + let forward = EdgeSpec::Chain { ctrl: ctrl.to_vec().into() }; + assert_eq!(forward.mid_key(), forward.reversed().mid_key()); + } + #[test] fn disconnected_closed_faces_keep_every_material_component() { let mut faces = box_faces(Vec3::ZERO, Vec3::new(2.0, 3.0, 4.0));