doc: a sweep round a closed path starts where the profile is (#3345)

A closed path was always followed from the start of its first picked curve,
wherever the profile stood on it. A profile drawn at the middle of a line was
then carried round the path standing off it by the distance between the two
(walls offset, the rounds turned the wrong way, on both kernels). Now the
closed chain begins where the profile's plane crosses it nearest the profile,
the curve there split in two (or at the joint there); open paths and paths
that already start on the profile's plane are unchanged.

Test: a 4 x 3 profile at the middle of the bottom line of a rounded rectangle
swept round it: Pappus volume and the tube within 2 mm of the path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt
This commit is contained in:
jyviko
2026-10-21 23:55:25 -04:00
parent ba99405e2f
commit 386b1d123f
3 changed files with 88 additions and 2 deletions
+56 -1
View File
@@ -5698,7 +5698,7 @@ fn feature_tools_with_constructor(
let points = path_points(ps, path, start)?;
let segs = match points {
Some(_) => Vec::new(),
None => path_segments(ps, path, start)?,
None => path_segments_at(ps, path, start, Some(&ss.plane))?,
};
let path_start =
points.as_ref().and_then(|p| p.points().first().copied()).or_else(|| segs.first().map(kernel::PathSeg::start)).unwrap_or(start);
@@ -5973,6 +5973,14 @@ fn path_points(ps: &SolvedSketch, ids: &[String], start: Vec3) -> Result<Option<
}
fn path_segments(ps: &SolvedSketch, ids: &[String], start: Vec3) -> Result<Vec<kernel::PathSeg>> {
path_segments_at(ps, ids, start, None)
}
/// [`path_segments`] for a profile in `profile`: a closed path is followed from where the
/// profile's plane crosses it nearest `start`, the curve there split in two, when that is not
/// already where the chain starts (#3345). Swept from anywhere else, the profile would stand
/// off the path by the distance between the two.
pub(crate) fn path_segments_at(ps: &SolvedSketch, ids: &[String], start: Vec3, profile: Option<&Plane>) -> Result<Vec<kernel::PathSeg>> {
if ids.is_empty() || ids.len() > 1000 {
return Err(DocError::Invalid("the path needs 1…1000 curves".into()));
}
@@ -6011,6 +6019,9 @@ fn path_segments(ps: &SolvedSketch, ids: &[String], start: Vec3) -> Result<Vec<k
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);
}
let n = ps.plane.normal();
Ok(segs
.iter()
@@ -6026,6 +6037,50 @@ fn path_segments(ps: &SolvedSketch, ids: &[String], start: Vec3) -> Result<Vec<k
.collect())
}
/// A closed chain `segs` (in the plane `path`) begun where `profile`'s plane crosses it nearest
/// `start`: the curve there split in two, its second part first and its first part last (or the
/// chain begun at the joint there). Unchanged when the chain is open, already starts on the
/// profile's plane, or its point nearest `start` is off that plane.
fn from_profile_on_closed_path(segs: Vec<solidcraft_geom::Seg2>, path: &Plane, profile: &Plane, start: Vec3) -> Vec<solidcraft_geom::Seg2> {
let (Some(first), Some(last)) = (segs.first(), segs.last()) else { return segs };
let size: f64 = segs.iter().map(solidcraft_geom::Seg2::length).sum();
let on_plane = |p: Vec2| profile.height(path.to_world(p)).abs() <= (size * 1e-9).max(1e-9);
if !start.is_finite() || !(size > 0.0 && size.is_finite()) || last.end().dist(first.start()) > 1e-5 || on_plane(first.start()) {
return segs;
}
let local = path.to_local(start);
let Some((k, t, q)) = segs
.iter()
.enumerate()
.map(|(k, s)| {
let t = s.closest_param(local);
(k, t, path.to_world(s.point_at(t)))
})
.min_by(|a, b| a.2.dist(start).total_cmp(&b.2.dist(start)))
else {
return segs;
};
if profile.height(q).abs() > (size * 1e-6).max(1e-6) {
return segs;
}
let Some(seg) = segs.get(k).copied() else { return segs };
let n = segs.len();
let from = |k: usize| segs.iter().cycle().skip(k % n).take(n).copied().collect::<Vec<_>>();
let at = |p: Vec2| path.to_world(p).dist(q) <= 1e-6;
if at(seg.start()) {
return from(k);
}
if at(seg.end()) {
return from(k + 1);
}
let (before, after) = seg.split_at(t);
let mut out = Vec::with_capacity(n + 1);
out.push(after);
out.extend(from(k + 1).into_iter().take(n - 1));
out.push(before);
out
}
/// Where an extrude of distance `d` starts and ends along the sketch normal, before any start
/// offset: one side, the other, both (symmetric), or a second distance on the other side.
///
+1 -1
View File
@@ -378,7 +378,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
kernel::sweep_sheet_points_with(&ss.plane, &chains, &pts, &ctl)?
}
None => {
let segs = path_segments(ps, path, start)?;
let segs = path_segments_at(ps, path, start, Some(&ss.plane))?;
let path_start = segs.first().map_or(start, kernel::PathSeg::start);
let rails = crate::eval::rail_polylines(doc, st, rails, f.component, path_start)?;
let ctl = crate::eval::sweep_control(vals, orientation, twist, scale, rails, *rail_fit)?;
@@ -161,3 +161,34 @@ fn hostile_sweep_options_are_refused() {
assert_eq!(s.doc.features.len(), before, "{bad}");
}
}
/// #3345: a 4 x 3 profile drawn at the middle of the bottom line of a closed rounded rectangle
/// (60 x 40, corners r8), swept round all of it, path picked from that line's start. The path is
/// followed from the profile: the tube stays on the path (Pappus: A L), within 2 mm of it
/// sideways, instead of standing off it by the half line the path's start lies away.
#[test]
fn a_sweep_round_a_closed_path_starts_where_the_profile_is() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Path"}));
run(&mut s, "sketch.line", json!({"points": [[-22, -20], [22, -20]], "infer": false, "ids": ["l1"]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [22, -12], "start": [22, -20], "end": [30, -12], "id": "a1"}));
run(&mut s, "sketch.line", json!({"points": [[30, -12], [30, 12]], "infer": false, "ids": ["l2"]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [22, 12], "start": [30, 12], "end": [22, 20], "id": "a2"}));
run(&mut s, "sketch.line", json!({"points": [[22, 20], [-22, 20]], "infer": false, "ids": ["l3"]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [-22, 12], "start": [-22, 20], "end": [-30, 12], "id": "a3"}));
run(&mut s, "sketch.line", json!({"points": [[-30, 12], [-30, -12]], "infer": false, "ids": ["l4"]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [-22, -12], "start": [-30, -12], "end": [-22, -20], "id": "a4"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, -20, 0], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}, "name": "Prof"}));
run(&mut s, "sketch.rectangle.center", json!({"center": [0, 0], "corner": [2, 1.5]}));
run(&mut s, "sketch.finish", json!({}));
let path = json!(["l1", "a1", "l2", "a2", "l3", "a3", "l4", "a4"]);
run(&mut s, "solid.sweep", json!({"sketch": "Prof", "path_sketch": "Path", "path": path, "operation": "new", "body_name": "Tube"}));
let m = measure(&s, "Tube");
let want = 12.0 * (2.0 * 44.0 + 2.0 * 24.0 + 4.0 * (PI / 2.0) * 8.0);
assert!((m.volume - want).abs() < want * 1e-3, "{} vs {want}", m.volume);
// Within the rig's 1e-3 of the box diagonal (the box is measured on a mesh).
for (got, want) in [(m.bbox.min.x, -32.0), (m.bbox.min.y, -22.0), (m.bbox.max.x, 32.0), (m.bbox.max.y, 22.0)] {
assert!((got - want).abs() < 0.08, "{:?}", m.bbox);
}
}