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:
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}");
|
||||
}
|
||||
@@ -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
@@ -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(®.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<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(®.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<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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user