From 3ca311f922f862f43c3eb29d40fd8ef211bf85cb Mon Sep 17 00:00:00 2001 From: semmelrinde <124386289+semmelrinde@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:45:56 +0000 Subject: [PATCH] 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 * Dog bone test: the outline goes outside, away from the picked bottom line Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../engine/src/cmd/offset_topology_tests.rs | 41 +++++++++++++++++++ crates/sketch/src/offset.rs | 28 ++++++++++--- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/offset_topology_tests.rs b/crates/engine/src/cmd/offset_topology_tests.rs index 1d418e1fa7..e6c792a848 100644 --- a/crates/engine/src/cmd/offset_topology_tests.rs +++ b/crates/engine/src/cmd/offset_topology_tests.rs @@ -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 = 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:?}"); +} diff --git a/crates/sketch/src/offset.rs b/crates/sketch/src/offset.rs index cace58562c..6508162b35 100644 --- a/crates/sketch/src/offset.rs +++ b/crates/sketch/src/offset.rs @@ -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), 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 { 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 }];