Sweep path: a curve of several pieces is turned round whole

The path chain oriented its first piece, not its first curve: a first curve
of several exact pieces (a fit spline, an ellipse, a projected free-form
edge) was never turned round, so a path whose next curve meets it at its
start was refused as "not connected end to end". The chain now orients
curve by curve: the first runs toward the second where they meet at one
end only (else from the end nearest the profile), each next one starts
where the previous ended, with one joint tolerance for both questions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-30 20:34:38 -04:00
committed by jyviko
parent e18fb40d8d
commit 9b58a5170c
2 changed files with 56 additions and 25 deletions
+34 -25
View File
@@ -5275,37 +5275,46 @@ pub(crate) fn path_segments_at(ps: &SolvedSketch, ids: &[String], start: Vec3, p
if let Some(path) = path_points(ps, ids, start)? {
return Ok(path.points().windows(2).map(|w| kernel::PathSeg::Line { a: w[0], b: w[1] }).collect());
}
let mut segs: Vec<solidcraft_geom::Seg2> = Vec::new();
// Each curve as its exact pieces (Béziers, conics) where it has them, in its own direction
// of travel. A curve is turned round whole: its pieces stay one run.
let mut curves: Vec<Vec<solidcraft_geom::Seg2>> = Vec::with_capacity(ids.len());
for id in ids {
let ci = ps.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("path curve `{id}`")))?;
// A free-form curve as its exact pieces (Béziers, conics) where it has them.
segs.extend(ps.sketch.exact_segs(ci).unwrap_or_else(|| ps.sketch.segs(ci)));
curves.push(ps.sketch.exact_segs(ci).unwrap_or_else(|| ps.sketch.segs(ci)));
}
// Orient the chain: the first segment starts at the end nearest `start`, each next one
// starts where the previous ended.
curves.retain(|c| !c.is_empty());
let ends = |c: &[solidcraft_geom::Seg2]| c.first().zip(c.last()).map(|(f, l)| (f.start(), l.end()));
let reversed = |c: Vec<solidcraft_geom::Seg2>| c.into_iter().rev().map(|s| s.reversed()).collect::<Vec<_>>();
// Where two curves meet: one tolerance for telling which ends touch and for the joint.
const JOINT: f64 = 1e-5;
// Orient the first curve: it runs toward the second where it meets it at one end only;
// otherwise (one curve, or a second meeting it at both ends) from the end nearest `start`
// (no start given, a pipe: as drawn).
let w = |p: Vec2| ps.plane.to_world(p);
if let (Some(f), true) = (segs.first().copied(), !segs.is_empty())
&& w(f.end()).dist(start) < w(f.start()).dist(start)
&& (segs.len() == 1 || segs.get(1).is_some_and(|n| n.start().dist(f.start()) < 1e-6 || n.end().dist(f.start()) < 1e-6))
{
segs[0] = f.reversed();
}
// No start given (a pipe): the first curve runs toward the second.
if !start.is_finite()
&& let (Some(f), Some(n)) = (segs.first().copied(), segs.get(1).copied())
&& (n.start().dist(f.start()) < 1e-6 || n.end().dist(f.start()) < 1e-6)
{
segs[0] = f.reversed();
}
for i in 1..segs.len() {
let prev_end = segs[i - 1].end();
if segs[i].end().dist(prev_end) < segs[i].start().dist(prev_end) {
segs[i] = segs[i].reversed();
}
if segs[i].start().dist(prev_end) > 1e-5 {
return Err(DocError::Invalid("the path curves are not connected end to end".into()));
if let Some((a, b)) = curves.first().and_then(|c| ends(c)) {
let touches = |p: Vec2| curves.get(1).and_then(|n| ends(n)).is_some_and(|(na, nb)| na.dist(p) <= JOINT || nb.dist(p) <= JOINT);
let flip = match (touches(a), touches(b)) {
(true, false) => true,
(false, true) => false,
_ => start.is_finite() && w(b).dist(start) < w(a).dist(start),
};
if flip && let Some(first) = curves.first_mut() {
*first = reversed(std::mem::take(first));
}
}
// Each next curve starts where the previous one ended.
let mut segs: Vec<solidcraft_geom::Seg2> = Vec::new();
for curve in curves {
let (Some(prev_end), Some((a, b))) = (segs.last().map(solidcraft_geom::Seg2::end), ends(&curve)) else {
segs.extend(curve);
continue;
};
let curve = if b.dist(prev_end) < a.dist(prev_end) { reversed(curve) } else { curve };
segs.extend(curve);
}
if segs.windows(2).any(|pair| pair[1].start().dist(pair[0].end()) > JOINT) {
return Err(DocError::Invalid("the path curves are not connected end to end".into()));
}
if let Some(profile) = profile {
segs = from_profile_on_closed_path(segs, &ps.plane, profile, start);
}
@@ -192,3 +192,25 @@ fn a_sweep_round_a_closed_path_starts_where_the_profile_is() {
assert!((got - want).abs() < 0.08, "{:?}", m.bbox);
}
}
/// #3604: a path whose first curve is a spline of several exact pieces, the next curve meeting
/// it at its start, follows the spline back from its end: a curve is turned round whole, not
/// piece by piece. A 2 x 2 profile at the spline's end (20, 0), swept along the spline through
/// (20, 0), (10, 0), (0, 0) and on along the line to (-15, 0): a 35 long bar.
#[test]
fn a_path_turns_a_spline_of_several_pieces_round_whole() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Path"}));
run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 0], [20, 0]], "ids": ["s1"]}));
run(&mut s, "sketch.line", json!({"points": [[-15, 0], [0, 0]], "infer": false, "ids": ["l1"]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [20, 0, 0], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}, "name": "Prof"}));
run(&mut s, "sketch.rectangle.center", json!({"center": [0, 0], "corner": [1, 1]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.sweep", json!({"sketch": "Prof", "path_sketch": "Path", "path": ["s1", "l1"], "operation": "new", "body_name": "Bar"}));
let m = measure(&s, "Bar");
assert!((m.volume - 140.0).abs() < 140.0 * 1e-6, "{}", m.volume);
for (got, want) in [(m.bbox.min.x, -15.0), (m.bbox.max.x, 20.0), (m.bbox.min.y, -1.0), (m.bbox.max.y, 1.0)] {
assert!((got - want).abs() < 0.04, "{:?}", m.bbox);
}
}