Sketch Trim/Extend/Offset robustness: Extend reaches splines, ellipses and conics (their polyline, then pinned on the curve); Offset refuses a distance its shape can't take (a straight piece overrun by its neighbours) instead of drawing a turned-inside-out copy; adversarial tests for trims (chords, tangent touches, lone lines, collinear overlaps, splines, ellipses, hostile points), extends and offsets (rectangles in and out, slots, splines, a spike, hostile distances), each leaving the sketch sound
Fixes #45 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
This commit is contained in:
1 parent
ab574952b1
commit
307ef11c0a
2 files changed
+198
-5
No files matched your search
@@ -376,11 +376,13 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if oi == ci {
|
||||
continue;
|
||||
}
|
||||
let Some(other) = sk.shape(oi) else { continue };
|
||||
for q in intersections(&unbounded, &other) {
|
||||
if on_bounded(&other, q).is_none() {
|
||||
continue;
|
||||
}
|
||||
// Lines and circles exactly; free-form curves (splines, ellipses, conics) by the
|
||||
// pieces of their polyline.
|
||||
let pieces: Vec<Shape> = match sk.shape(oi) {
|
||||
Some(other) => vec![other],
|
||||
None => sk.polyline(oi).windows(2).map(|w| Shape::Line { a: w[0], b: w[1] }).collect(),
|
||||
};
|
||||
for q in pieces.iter().flat_map(|other| intersections(&unbounded, other).into_iter().filter(|q| on_bounded(other, *q).is_some())) {
|
||||
let t = sh.param(q);
|
||||
let gap = match sh {
|
||||
Shape::Line { a, b } => {
|
||||
@@ -847,6 +849,18 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !closed {
|
||||
joints.push(end_of(&pieces[n - 1]));
|
||||
}
|
||||
// A straight piece whose joints come out reversed (or meet) was overrun by its
|
||||
// neighbours: the offset is larger than the shape allows there.
|
||||
for (k, pc) in pieces.iter().enumerate() {
|
||||
if let Piece::Line(a, b) = pc {
|
||||
let (ja, jb) = (joints.get(k).copied(), joints.get(if closed { (k + 1) % n } else { k + 1 }).copied());
|
||||
if let (Some(ja), Some(jb)) = (ja, jb)
|
||||
&& (jb - ja).dot(*b - *a) <= TOL * a.dist(*b)
|
||||
{
|
||||
return Err(bad(cmd, "the offset is larger than the shape allows (the offset curves would cross)"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let pts: Vec<usize> = joints.iter().map(|q| sk.add_point(*q, None)).collect::<std::result::Result<_, _>>()?;
|
||||
for (k, pc) in pieces.iter().enumerate() {
|
||||
let (pa, pb) = (pts[k], if closed { pts[(k + 1) % n] } else { pts[k + 1] });
|
||||
@@ -1149,3 +1163,7 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_modify_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_robust_tests.rs"]
|
||||
mod robust_tests;
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Adversarial Trim, Extend and Offset cases (#45): each gives the expected result or a clear
|
||||
//! error, and the sketch always stays sound (it solves, every point is finite, nothing
|
||||
//! degenerate is left behind).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn new_sketch() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn curves(r: &Value) -> Vec<String> {
|
||||
r["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, id: &str, p: Value) -> Vec<String> {
|
||||
match s.execute(id, &p) {
|
||||
Ok(r) => curves(&r),
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The sketch is sound: it solves, every point is finite, no curve has collapsed.
|
||||
fn sound(s: &Session) {
|
||||
let id = s.active_sketch.unwrap();
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(id).unwrap();
|
||||
assert!(ss.report.ok(), "the sketch no longer solves: {:?}", ss.report.failing);
|
||||
for p in &ss.sketch.points {
|
||||
assert!(p.pos.is_finite(), "point {} is {:?}", p.id, p.pos);
|
||||
}
|
||||
for (i, c) in ss.sketch.curves.iter().enumerate() {
|
||||
let len: f64 = ss.sketch.segs(i).iter().map(|g| g.length()).sum();
|
||||
assert!(len.is_finite() && len > 1e-9, "curve {} has length {len}", c.id);
|
||||
}
|
||||
}
|
||||
|
||||
fn count(s: &Session) -> usize {
|
||||
let id = s.active_sketch.unwrap();
|
||||
s.model.state().sketch(id).unwrap().sketch.curves.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_cases() {
|
||||
// A line crossing a circle twice: trimming the chord leaves the two outer stubs.
|
||||
let mut s = new_sketch();
|
||||
add(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}));
|
||||
let l = add(&mut s, "sketch.line", json!({"points": [[-20, 0], [20, 0]]}))[0].clone();
|
||||
s.execute("sketch.trim", &json!({"curve": l, "at": [0, 0]})).unwrap();
|
||||
sound(&s);
|
||||
assert_eq!(count(&s), 3, "circle + two stubs");
|
||||
// Trimming at a tangent touch, at a curve end, on a curve with no crossings.
|
||||
let mut s = new_sketch();
|
||||
let c = add(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}))[0].clone();
|
||||
add(&mut s, "sketch.line", json!({"points": [[-20, 10], [20, 10]]}));
|
||||
let r = s.execute("sketch.trim", &json!({"curve": c, "at": [0, -10]}));
|
||||
sound(&s);
|
||||
let _ = r;
|
||||
let mut s = new_sketch();
|
||||
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}))[0].clone();
|
||||
// No crossings: the whole line goes (Fusion deletes it).
|
||||
let r = s.execute("sketch.trim", &json!({"curve": l, "at": [5, 0]}));
|
||||
sound_or_empty(&s);
|
||||
assert!(r.is_ok() || r.is_err());
|
||||
// Overlapping collinear lines.
|
||||
let mut s = new_sketch();
|
||||
let a = add(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))[0].clone();
|
||||
add(&mut s, "sketch.line", json!({"points": [[5, 0], [15, 0]]}));
|
||||
let _ = s.execute("sketch.trim", &json!({"curve": a, "at": [10, 0]}));
|
||||
sound(&s);
|
||||
// A spline crossed by a line, trimmed between the crossings; an ellipse cut by a line.
|
||||
let mut s = new_sketch();
|
||||
let sp = add(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 10], [20, -10], [30, 0]]}))[0].clone();
|
||||
add(&mut s, "sketch.line", json!({"points": [[-5, 2], [35, 2]]}));
|
||||
s.execute("sketch.trim", &json!({"curve": sp, "at": [10, 9]})).unwrap();
|
||||
sound(&s);
|
||||
let mut s = new_sketch();
|
||||
let e = add(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}))[0].clone();
|
||||
add(&mut s, "sketch.line", json!({"points": [[-15, 0], [15, 0]]}));
|
||||
s.execute("sketch.trim", &json!({"curve": e, "at": [0, -4]})).unwrap();
|
||||
sound(&s);
|
||||
// Hostile points.
|
||||
let mut s = new_sketch();
|
||||
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}))[0].clone();
|
||||
for at in [json!([f64::MAX, 0]), json!([1e300, 1e300]), json!("x"), json!([0])] {
|
||||
let _ = s.execute("sketch.trim", &json!({"curve": l, "at": at}));
|
||||
sound(&s);
|
||||
}
|
||||
}
|
||||
|
||||
fn sound_or_empty(s: &Session) {
|
||||
if count(s) > 0 {
|
||||
sound(s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_cases() {
|
||||
// A line extends to a circle; to a spline; with nothing to reach it is a clear error.
|
||||
let mut s = new_sketch();
|
||||
add(&mut s, "sketch.circle.center", json!({"center": [20, 0], "radius": 5}));
|
||||
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [5, 0]]}))[0].clone();
|
||||
s.execute("sketch.extend", &json!({"curve": l, "at": [5, 0]})).unwrap();
|
||||
sound(&s);
|
||||
let id = s.active_sketch.unwrap();
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(id).unwrap();
|
||||
let li = ss.sketch.curve_index(&l).unwrap();
|
||||
let end = ss.sketch.segs(li).iter().map(|g| g.end().x.max(g.start().x)).fold(f64::NEG_INFINITY, f64::max);
|
||||
assert!((end - 15.0).abs() < 1e-6, "{end}");
|
||||
drop(st);
|
||||
let mut s = new_sketch();
|
||||
add(&mut s, "sketch.spline.fit_point", json!({"points": [[20, -10], [22, 0], [20, 10]]}));
|
||||
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [5, 0]]}))[0].clone();
|
||||
s.execute("sketch.extend", &json!({"curve": l, "at": [5, 0]})).unwrap();
|
||||
sound(&s);
|
||||
let mut s = new_sketch();
|
||||
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [5, 0]]}))[0].clone();
|
||||
assert!(s.execute("sketch.extend", &json!({"curve": l, "at": [5, 0]})).is_err());
|
||||
sound(&s);
|
||||
// An arc extends along its circle to a line.
|
||||
let mut s = new_sketch();
|
||||
add(&mut s, "sketch.line", json!({"points": [[-20, -3], [20, -3]]}));
|
||||
let a = add(&mut s, "sketch.arc.three_point", json!({"start": [10, 0], "end": [0, 10], "through": [7.0710678, 7.0710678]}))[0].clone();
|
||||
let _ = s.execute("sketch.extend", &json!({"curve": a, "at": [10, 0]}));
|
||||
sound(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_cases() {
|
||||
// A rectangle offset outward and inward; inward past its middle vanishes (clear error).
|
||||
let mut s = new_sketch();
|
||||
let r = add(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
|
||||
let n0 = count(&s);
|
||||
s.execute("sketch.offset", &json!({"curves": [r[0].clone()], "distance": 2, "side": [-5, -5]})).unwrap();
|
||||
sound(&s);
|
||||
assert!(count(&s) > n0);
|
||||
let n1 = count(&s);
|
||||
s.execute("sketch.offset", &json!({"curves": [r[0].clone()], "distance": 2, "side": [10, 5]})).unwrap();
|
||||
sound(&s);
|
||||
assert!(count(&s) > n1);
|
||||
let n2 = count(&s);
|
||||
assert!(
|
||||
s.execute("sketch.offset", &json!({"curves": [r[0].clone()], "distance": 6, "side": [10, 5]})).is_err(),
|
||||
"inward 6 on a 10 wide rectangle"
|
||||
);
|
||||
assert_eq!(count(&s), n2, "a failed offset adds nothing");
|
||||
sound(&s);
|
||||
// A slot (lines and arcs), a spline chain, an open zig-zag whose inner offset self-crosses.
|
||||
let mut s = new_sketch();
|
||||
let slot = add(&mut s, "sketch.slot.center_to_center", json!({"p0": [0, 0], "p1": [20, 0], "width": 6}));
|
||||
if let Some(c) = slot.first() {
|
||||
s.execute("sketch.offset", &json!({"curves": [c], "distance": 1, "side": [10, 10]})).unwrap();
|
||||
sound(&s);
|
||||
}
|
||||
let mut s = new_sketch();
|
||||
let sp = add(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 6], [20, -6], [30, 0]]}))[0].clone();
|
||||
s.execute("sketch.offset", &json!({"curves": [sp], "distance": 1.5})).unwrap();
|
||||
sound(&s);
|
||||
let mut s = new_sketch();
|
||||
let z = add(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0], [10.5, 5], [11, 0], [20, 0]]}));
|
||||
let r = s.execute("sketch.offset", &json!({"curves": [z[0].clone()], "distance": 2, "side": [10, -5]}));
|
||||
sound(&s);
|
||||
let _ = r;
|
||||
// Hostile distances.
|
||||
let mut s = new_sketch();
|
||||
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}))[0].clone();
|
||||
for d in [json!(0), json!(-1e308), json!(f64::MAX), json!("x"), json!(1e-12)] {
|
||||
let _ = s.execute("sketch.offset", &json!({"curves": [l.clone()], "distance": d}));
|
||||
sound(&s);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user