Text cuts: the 2D prism route takes a font's curves exactly (#1429)

The 2D prism route for text cuts takes a font's curves exactly instead of as polylines.
This commit is contained in:
phanoob authored and GitHub committed 2026-10-15 13:32:02 -04:00
1 parent f0c5bfcdf2
commit 3047aa60cd
6 files changed
+647 -62

No files matched your search

@@ -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}");
}
+5 -9
View File
@@ -500,15 +500,11 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
// 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()));
}
}
+443 -50
View File
@@ -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<usize>,
pub start: Option<usize>,
pub end: Option<usize>,
/// 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<usize>,
}
/// 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<Vec<R
}
/// [`boolean2d`] of tagged sections: each segment of the result tagged as the segment of `a` or
/// `b` it is (a piece of).
/// `b` it is (a piece of). Free-form segments (a font's curves) take part as arcs within
/// [`PLANAR_FIT_TOL`]; each piece of one in the result is the exact curve again, ending where
/// it exactly meets the segment next to it (see [`exact_again`]).
fn boolean2d_tagged(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Section> {
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<Seg2>, fit: f64) -> Option<Section> {
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(&reg.outer).chain(&reg.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<f64>, 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<f64>, 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<Seg2> {
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(&reg.outer).chain(&reg.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<Seg2>, Vec<Tag>) {
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::<Vec<_>>());
// 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<Piece> = 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<Section> {
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<Tag>, near: f64) -> Vec<(Loop2, Vec<Tag>)>
}
fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option<Vec<Seg2>> {
if !matches!(seg, Seg2::Line { .. } | Seg2::Arc { .. }) {
return None;
}
let mut ts: Vec<f64> = pts
.iter()
.filter_map(|p| {
@@ -1372,7 +1676,7 @@ fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option<Vec<Seg2>> {
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<Vec2> = Vec::new();
let mut grid: std::collections::HashMap<(i64, i64), Vec<usize>> = std::collections::HashMap::new();
let weld = |ends: &[Vec2], grid: &std::collections::HashMap<(i64, i64), Vec<usize>>, u: Vec2| -> Option<Vec2> {
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<EdgeSpec> {
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<Seg2> = 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<Seg2> = if *up { segs } else { segs.iter().rev().map(Seg2::reversed).collect() };
loops.push(segs.iter().map(|sg| edge(sg, *z)).collect::<Vec<_>>());
loops.push(segs.iter().map(|sg| edge(sg, *z)).collect::<Option<Vec<_>>>()?);
}
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<Result<Option<
fn simple_prism_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2]) -> Option<Result<Option<Body>>> {
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<Vec3> = 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<Vec3> = 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<Vec<Vec3>> {
if a.face_count().saturating_add(b.face_count()) > MAX_PLANAR_SEGMENTS * 2 {
return None;
}
let normals = |x: &Body| -> Vec<Vec3> {
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<Vec3> = 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<Vec3> = 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<Vec3> = 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::<f64>()).sum::<f64>();
// 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::<usize>();
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
+55 -1
View File
@@ -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<H>], a: Vec2, b: Vec2, tol: f64) -> Option<Vec<Seg2>> {
let [span] = spans else { return None };
let pts: Vec<Vec2> = 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<H>], plane: &Plane, a: Vec2, b: Vec2, tol: f64, error: f64, work: &mut usize) -> Option<Vec<Seg2>> {
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;
+87
View File
@@ -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(&region), 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(&region), 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}");
}
+28 -2
View File
@@ -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));