mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-24 10:56:51 -06:00
* Close hatch boundaries where a line ends just short of a flattened ellipse (#1690) * Bound the hatch endpoint snap scan by segment count and visited segments (#1690) * test: LINE returns its new objects as `handles` Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
1c35d7f8c1
commit
c92bb5cdad
@@ -34,12 +34,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// The tolerance curves are flattened with for boundary tracing, in a space with extents `ext`.
|
||||
pub(super) fn outline_tol(ext: Bounds2) -> f64 {
|
||||
(ext.width() + ext.height()).max(1e-9) / 20000.0
|
||||
}
|
||||
|
||||
/// Tessellated outlines of every visible curve in the space (hatch boundary candidates), each
|
||||
/// with the handle of the object it came from.
|
||||
fn outlines(d: &Drawing, space: &Space, near: Option<Bounds2>) -> (Vec<Vec<Vec2>>, Vec<Handle>) {
|
||||
let Some(store) = d.space(space) else { return (Vec::new(), Vec::new()) };
|
||||
let ext = d.extents(space);
|
||||
let tol = (ext.width() + ext.height()).max(1e-9) / 20000.0;
|
||||
let tol = outline_tol(ext);
|
||||
let mut out = Vec::new();
|
||||
let mut owners = Vec::new();
|
||||
for e in store.iter() {
|
||||
@@ -206,6 +211,8 @@ pub(crate) fn loops_at(s: &Session, p: Vec2) -> Result<Vec<HatchLoop>> {
|
||||
let ext = d.extents(&space);
|
||||
let tol = ((ext.width() + ext.height()) * 1e-9).max(1e-9);
|
||||
let (polys, owners) = outlines(d, &space, None);
|
||||
// A line that ends on a flattened curve (an ellipse) can stop short of its chords.
|
||||
let polys = cadcraft_geom::region::snap_ends(&polys, outline_tol(ext), tol);
|
||||
let found = cadcraft_geom::region::boundary_at_gap(&polys, p, tol, gap_tolerance(d))
|
||||
.ok_or_else(|| EngineError::Other("Valid hatch boundary not found.".into()))?;
|
||||
let outer = found.outer;
|
||||
|
||||
@@ -86,9 +86,10 @@ fn refill(before: &Drawing, after: &Drawing, l: &HatchLoop) -> Refill {
|
||||
if nb.is_empty() {
|
||||
return Refill::Open;
|
||||
}
|
||||
let tol = (nb.width() + nb.height()).max(1e-9) / 20000.0;
|
||||
let polys: Vec<Vec<Vec2>> = live.iter().filter_map(|h| after.entity(*h)).flat_map(|e| object_polys(after, e, tol, nb)).collect();
|
||||
let tol = super::outline_tol(nb);
|
||||
let snap = ((nb.width() + nb.height()) * 1e-9).max(1e-9);
|
||||
let polys: Vec<Vec<Vec2>> = live.iter().filter_map(|h| after.entity(*h)).flat_map(|e| object_polys(after, e, tol, nb)).collect();
|
||||
let polys = region::snap_ends(&polys, tol, snap);
|
||||
let gap = gap_tolerance(after);
|
||||
let found = if l.outer {
|
||||
// The face around a point inside the old loop, carried along with the objects.
|
||||
|
||||
@@ -42,6 +42,26 @@ fn gap_tolerance_closes_small_gaps() {
|
||||
assert!(hatch(&s, &h).associative);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_ending_on_an_ellipse_close_a_boundary() {
|
||||
// #1690: the verticals end on the true ellipse, which the boundary tracer sees as chords
|
||||
// that pass just below those ends.
|
||||
let mut s = Session::new();
|
||||
s.execute("ellipse", &json!({ "center": [0, 0], "major": [10, 0], "ratio": 0.5 })).unwrap();
|
||||
for x in [1.0f64, 2.0] {
|
||||
let y = 5.0 * (1.0 - x * x / 100.0).sqrt();
|
||||
s.execute("line", &json!({ "points": [[x, 8.0], [x, y]] })).unwrap();
|
||||
}
|
||||
let top = s.execute("line", &json!({ "points": [[0, 8], [3, 8]] })).unwrap()["handles"][0].clone();
|
||||
let h = handle(&s.execute("hatch", &json!({ "points": [[1.5, 7]] })).unwrap());
|
||||
let hh = hatch(&s, &h);
|
||||
assert_eq!(hh.loops.len(), 1);
|
||||
assert_eq!(hh.loops[0].sources.len(), 4);
|
||||
// The associative refill traces the same way: sliding the top edge keeps the hatch.
|
||||
s.execute("move", &json!({ "handles": [top], "delta": [0.5, 0] })).unwrap();
|
||||
assert!(hatch(&s, &h).associative);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_origin_options() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -434,6 +434,88 @@ fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Vec<Island> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The curves with each open end that stops within `snap` of another curve moved onto that
|
||||
/// curve (which gains a vertex there), so the two meet exactly. A curve that is flattened
|
||||
/// into chords (an ellipse, a spline) lies up to the flattening tolerance off the true curve,
|
||||
/// so a line that ends on the true curve can stop just short of the chords.
|
||||
pub fn snap_ends(polys: &[Vec<Vec2>], snap: f64, tol: f64) -> Vec<Vec<Vec2>> {
|
||||
let mut out: Vec<Vec<Vec2>> = polys.to_vec();
|
||||
if !(snap.is_finite() && snap > tol) {
|
||||
return out;
|
||||
}
|
||||
// build() refuses this input anyway; don't spend ends x segments work on it.
|
||||
let total: usize = polys.iter().map(|p| p.len().saturating_sub(1)).sum();
|
||||
if total > MAX_SEGMENTS {
|
||||
return out;
|
||||
}
|
||||
// (target polyline, segment index, parameter on it, the point) for every vertex to insert.
|
||||
let mut inserts: Vec<(usize, usize, f64, Vec2)> = Vec::new();
|
||||
let mut budget = MAX_GAP_PAIRS;
|
||||
'ends: for (i, pl) in polys.iter().enumerate() {
|
||||
let (Some(&first), Some(&last)) = (pl.first(), pl.last()) else { continue };
|
||||
if pl.len() < 2 || first.near(last, tol) {
|
||||
continue;
|
||||
}
|
||||
for at_end in [false, true] {
|
||||
let e = if at_end { last } else { first };
|
||||
if !e.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let mut best: Option<(f64, usize, usize, f64, Vec2)> = None;
|
||||
let mut touching = false;
|
||||
for (j, other) in polys.iter().enumerate() {
|
||||
if j == i {
|
||||
continue;
|
||||
}
|
||||
for (k, w) in other.windows(2).enumerate() {
|
||||
if budget == 0 {
|
||||
break 'ends;
|
||||
}
|
||||
budget -= 1;
|
||||
let [a, b] = w else { continue };
|
||||
if !(a.is_finite() && b.is_finite())
|
||||
|| e.x < a.x.min(b.x) - snap
|
||||
|| e.x > a.x.max(b.x) + snap
|
||||
|| e.y < a.y.min(b.y) - snap
|
||||
|| e.y > a.y.max(b.y) + snap
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let line = Line::new(*a, *b);
|
||||
let t = line.param_of(e).clamp(0.0, 1.0);
|
||||
let q = line.at(t);
|
||||
let d = q.dist(e);
|
||||
if d <= tol {
|
||||
touching = true;
|
||||
} else if d <= snap && best.is_none_or(|(bd, ..)| d < bd) {
|
||||
best = Some((d, j, k, t, q));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((_, j, k, t, q)) = best.filter(|_| !touching) else { continue };
|
||||
let Some(slot) = out.get_mut(i).and_then(|v| if at_end { v.last_mut() } else { v.first_mut() }) else { continue };
|
||||
*slot = q;
|
||||
let ends = polys.get(j).and_then(|o| o.get(k).zip(o.get(k + 1)));
|
||||
if let Some((&a, &b)) = ends
|
||||
&& !q.near(a, tol)
|
||||
&& !q.near(b, tol)
|
||||
{
|
||||
inserts.push((j, k, t, q));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Later segments and larger parameters first, so earlier indices stay valid.
|
||||
inserts.sort_by(|x, y| y.0.cmp(&x.0).then(y.1.cmp(&x.1)).then(y.2.total_cmp(&x.2)));
|
||||
for (j, k, _, q) in inserts {
|
||||
if let Some(v) = out.get_mut(j)
|
||||
&& k < v.len()
|
||||
{
|
||||
v.insert(k + 1, q);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The closed loop enclosing `p`, counter-clockwise, or `None`.
|
||||
pub fn enclosing_loop(polys: &[Vec<Vec2>], p: Vec2, tol: f64) -> Option<Vec<Vec2>> {
|
||||
enclosing_loop_gap(polys, p, tol, 0.0)
|
||||
@@ -721,4 +803,34 @@ mod tests {
|
||||
assert!(enclosing_loop_gap(&polys, p, 1e-9, f64::NAN).is_none());
|
||||
assert!(enclosing_loop_gap(&polys, p, 1e-9, f64::INFINITY).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_ending_just_short_of_a_chord_is_joined_to_it() {
|
||||
// A strip between two verticals and a flattened arc that bows below the ends of the
|
||||
// verticals by 0.05 (the verticals stop on the true curve, the chord is lower).
|
||||
let top = vec![Vec2::new(0.0, 10.0), Vec2::new(2.0, 10.0)];
|
||||
let left = vec![Vec2::new(0.0, 10.0), Vec2::new(0.0, 0.05)];
|
||||
let right = vec![Vec2::new(2.0, 10.0), Vec2::new(2.0, 0.05)];
|
||||
let chord = vec![Vec2::new(-5.0, 0.0), Vec2::new(5.0, 0.0)];
|
||||
let polys = vec![top, left, right, chord];
|
||||
let p = Vec2::new(1.0, 5.0);
|
||||
assert!(enclosing_loop(&polys, p, 1e-9).is_none());
|
||||
let snapped = snap_ends(&polys, 0.1, 1e-9);
|
||||
let l = enclosing_loop(&snapped, p, 1e-9).unwrap();
|
||||
assert!((shoelace(&l) - 20.0).abs() < 1e-6, "{}", shoelace(&l));
|
||||
// Too far away: left alone.
|
||||
assert_eq!(snap_ends(&polys, 0.01, 1e-9), polys);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_ends_skips_input_over_the_segment_cap() {
|
||||
let mut polys: Vec<Vec<Vec2>> = (0..=MAX_SEGMENTS)
|
||||
.map(|i| {
|
||||
let x = i as f64 * 10.0;
|
||||
vec![Vec2::new(x, 0.0), Vec2::new(x, 1.0)]
|
||||
})
|
||||
.collect();
|
||||
polys.push(vec![Vec2::new(0.0, 1.05), Vec2::new(5.0, 1.05)]);
|
||||
assert_eq!(snap_ends(&polys, 0.1, 1e-9), polys);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user