Sketch: a dog bone's outline outside it meets outside both notches (#10984) (#11000)

* Sketch: a dog bone's outline outside it meets outside both notches (#10984)

Where two tangent arcs touch at a cusp, the copies of the two arcs cross
twice, both crossings as far from the cusp, and rounding chose one: on
one side of a dog bone the outline ran both copies on and drew a loop
into the bone. Such a tie now takes the crossing that cuts both copies
short.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Dog bone test: the outline goes outside, away from the picked bottom line

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
semmelrinde
2026-11-02 13:45:56 +00:00
committed by GitHub
parent b978eeddcd
commit 3ca311f922
2 changed files with 63 additions and 6 deletions
@@ -107,3 +107,44 @@ fn inset_past_corner_radius_creates_a_real_boundary_and_saved_profile_identity()
assert_eq!(session.redo.len(), redo);
}
}
/// #10984: a dog bone whose round ends touch at a cusp on each side. Drawn 2 mm outside, the
/// outline meets in each notch outside the bone, where the two ends' copies cross before
/// their ends. One notch ran both copies on to their far crossing instead, a loop into the
/// bone, as both crossings are as far from the notch.
#[test]
fn an_outline_outside_a_dog_bone_meets_outside_both_notches() {
let mut session = Session::default();
run(&mut session, "sketch.create", json!({"plane":"XY","name":"Bone"}));
let sketch = session.active_sketch.unwrap();
run(&mut session, "sketch.line", json!({"points":[[-10,-10],[10,-10]]}));
run(&mut session, "sketch.arc.center_point", json!({"center":[20,-10],"start":[10,-10],"end":[20,0]}));
run(&mut session, "sketch.arc.center_point", json!({"center":[20,10],"start":[20,0],"end":[10,10]}));
run(&mut session, "sketch.line", json!({"points":[[10,10],[-10,10]]}));
run(&mut session, "sketch.arc.center_point", json!({"center":[-20,10],"start":[-10,10],"end":[-20,0]}));
run(&mut session, "sketch.arc.center_point", json!({"center":[-20,-10],"start":[-20,0],"end":[-10,-10]}));
run(&mut session, "sketch.offset", json!({"curves":["l1"],"distance":2,"side":[0,-40]}));
let state = session.model.state();
let sk = &state.sketch(sketch).unwrap().sketch;
let record = sk.offsets.last().unwrap();
assert_eq!(record.outputs.len(), 6);
let mut notches: Vec<f64> = Vec::new();
for id in &record.outputs {
let curve = &sk.curves[sk.curve_index(id).unwrap()];
let (a, b) = match curve.kind {
solidcraft_sketch::CurveKind::Line { a, b } | solidcraft_sketch::CurveKind::Arc { a, b, .. } => (a, b),
_ => panic!("{id} is no line or arc"),
};
for p in [sk.points[a].pos, sk.points[b].pos] {
if p.y.abs() < 1e-6 {
notches.push(p.x);
}
}
}
notches.sort_by(f64::total_cmp);
// The copies have radius 12 about centres 20 apart: they cross 12² − 10² = 44, √44 off
// the notch's line, outside it: x = ±(20 + √44).
let outside = 20.0 + 44f64.sqrt();
assert_eq!(notches.len(), 4, "{notches:?}");
assert!(notches.iter().zip([-outside, -outside, outside, outside]).all(|(x, want)| (x - want).abs() < 1e-6), "{notches:?}");
}
+22 -6
View File
@@ -247,7 +247,7 @@ impl Sketch {
let (a, b) = (self.point(a).ok_or_else(bad)?, self.point(b).ok_or_else(bad)?);
let (a, b) = if *forward { (a, b) } else { (b, a) };
let n = (b - a).normalized().ok_or_else(bad)?.perp() * offset.distance;
(Shape::Line { a: a + n, b: b + n }, a + n, b + n)
(Shape::Line { a: a + n, b: b + n }, a + n, b + n, None)
}
CurveKind::Arc { c, a, b } => {
let (c, a, b) = (self.point(c).ok_or_else(bad)?, self.point(a).ok_or_else(bad)?, self.point(b).ok_or_else(bad)?);
@@ -258,7 +258,7 @@ impl Sketch {
let a = c + (a - c).normalized().ok_or_else(bad)? * r;
let b = c + (b - c).normalized().ok_or_else(bad)? * r;
let (a, b) = if *forward { (a, b) } else { (b, a) };
(Shape::Round { c, r, start: 0.0, sweep: std::f64::consts::TAU }, a, b)
(Shape::Round { c, r, start: 0.0, sweep: std::f64::consts::TAU }, a, b, Some(*forward))
}
CurveKind::Circle { c, r } => {
let c = self.point(c).ok_or_else(bad)?;
@@ -282,16 +282,32 @@ impl Sketch {
if pieces.is_empty() {
continue;
}
// Which way a piece runs at `p`: a line along itself, an arc anticlockwise round its
// centre when it follows its source forward.
let along = |piece: &(Shape, Vec2, Vec2, Option<bool>), p: Vec2| match (&piece.0, piece.3) {
(Shape::Line { a, b }, _) => *b - *a,
(Shape::Round { c, .. }, Some(true)) => (p - *c).perp(),
(Shape::Round { c, .. }, Some(false)) => (*c - p).perp(),
_ => Vec2::new(0.0, 0.0),
};
let joint = |i: usize, j: usize| -> Result<Vec2, SketchError> {
let (x, y) = (pieces.get(i).ok_or_else(bad)?, pieces.get(j).ok_or_else(bad)?);
if x.2.dist(y.1) < 1e-6 {
return Ok(x.2);
}
let guess = (x.2 + y.1) * 0.5;
intersections(&x.0, &y.0)
.into_iter()
.min_by(|a, b| a.dist(guess).total_cmp(&b.dist(guess)))
.ok_or_else(|| SketchError::Invalid("the offset curves no longer meet".into()))
let mut found = intersections(&x.0, &y.0);
found.sort_by(|a, b| a.dist(guess).total_cmp(&b.dist(guess)));
// Where the sources touch, as two tangent arcs do at a dog bone's notch (#10984),
// both crossings are as far from the corner and rounding chose one. The pieces
// overlap there: they meet where both are cut short, not where both run on and
// cross into a loop.
let cut_short = |p: Vec2| (p - x.2).dot(along(x, x.2)) < 0.0 && (p - y.1).dot(along(y, y.1)) > 0.0;
match found.as_slice() {
[p, q, ..] if (q.dist(guess) - p.dist(guess)).abs() <= 1e-9 * (1.0 + p.dist(*q)) && !cut_short(*p) && cut_short(*q) => Ok(*q),
[p, ..] => Ok(*p),
[] => Err(SketchError::Invalid("the offset curves no longer meet".into())),
}
};
let n = pieces.len();
let mut joints = vec![if offset.closed { joint(n - 1, 0)? } else { pieces.first().ok_or_else(bad)?.1 }];