mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 10:35:58 +00:00
Twisted sweep: circles about a straight path are exact cylinders (solidcore#2184)
A twist about a straight path maps a profile circle centred on the path's line onto itself, so its swept wall is exactly the cylinder. The skin through the stations stayed within 3.9e-9 of it (6.6e-9 on the outer wall), and a boolean with another body on that cylinder failed with status 1001 (solidcore#2184; the kernel's handling of a B-spline face lying on a cylinder stays open there). sweep_controlled now builds those walls exactly when the path is lines along the profile's normal and there is no end scale or rails: a profile whose every loop is such a circle is swept untwisted, and such holes in another outer loop are cut as exact cylinders from the skinned sweep of the rest. Other loops, and an axis-centred arc inside a loop of other curves, are skinned as before; a failed cut falls back to skinning all. Regression test in smlib_sweeps_tests.rs: #2184's tubes joined at 10, 16, 18, 20 and 30 degrees give one closed body of 11.5625 pi with cylinder walls; a twisted hexagon with the bore keeps a cylinder bore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdcgEtwSrdwZxqTQp5MfnU
This commit is contained in:
@@ -985,14 +985,139 @@ mod imp {
|
||||
/// the path by [`crate::sweep_path`]'s laws (the same the polyline sweep uses), each section
|
||||
/// exactly the profile's image, and SMLib skins the walls through them. Round a closed path the
|
||||
/// last section has to land on the first, and the ends are joined.
|
||||
///
|
||||
/// Along a straight path square to the profile, with no end scale or rails, a loop that is a
|
||||
/// circle centred on the path's line maps onto itself at every station, and its wall is built
|
||||
/// as the exact cylinder: a profile of such circles alone is swept untwisted, and such holes
|
||||
/// in another outer loop are cut from the sweep of the rest. Other loops are skinned, and an
|
||||
/// arc centred on the line within a loop of other curves still is.
|
||||
pub(crate) fn sweep_controlled(plane: &Plane, region: &NumberedRegion, path: &[crate::PathSeg], ctl: &crate::SweepControl) -> Result<Body> {
|
||||
let runs = smooth_runs(path)?;
|
||||
if runs.len() > 1 {
|
||||
return cornered_controlled(plane, region, path, &runs, ctl);
|
||||
}
|
||||
let pieces = pieces(region.loops());
|
||||
let c = curves(plane, &pieces)?;
|
||||
let start = path.first().map(crate::PathSeg::start).ok_or_else(|| KernelError::Invalid("the path needs 1…1000 segments".into()))?;
|
||||
// A circle centred on the twist axis, swept straight along that axis, turns onto itself:
|
||||
// its wall is exactly the cylinder, built as one rather than skinned (solidcore#2184: a
|
||||
// skin lying within 4e-9 of a cylinder another body shares fails the boolean).
|
||||
let axial = straight_axis(plane, path, ctl, start).map(|(k, length)| {
|
||||
let round: Vec<Option<(Vec3, f64)>> = region.loops().map(|l| circle_on_axis(plane, &l.lp, start, k)).collect();
|
||||
(k, length, round)
|
||||
});
|
||||
let mut bores: Vec<(Vec3, f64)> = Vec::new();
|
||||
let mut kept = region.clone();
|
||||
if let Some((_, _, round)) = &axial {
|
||||
if round.iter().all(Option::is_some) {
|
||||
// Every loop a circle about the axis: the twist changes nothing.
|
||||
match sweep(plane, &pieces, path, false) {
|
||||
Ok(body) => return Ok(body),
|
||||
Err(e) => log::debug!(target: "smlib", "twisted sweep of circles about its path, swept untwisted: {e}; skinned instead"),
|
||||
}
|
||||
} else if round.first().is_some_and(Option::is_none) {
|
||||
// The holes that are circles about the axis are cut as exact cylinders from the
|
||||
// sweep of the rest, through and a little past both ends.
|
||||
let (mut holes, mut cut) = (Vec::new(), Vec::new());
|
||||
for (h, r) in region.holes.iter().zip(round.iter().skip(1)) {
|
||||
match r {
|
||||
Some(circle) => cut.push(*circle),
|
||||
None => holes.push(h.clone()),
|
||||
}
|
||||
}
|
||||
if !cut.is_empty() {
|
||||
bores = cut;
|
||||
kept = NumberedRegion { outer: region.outer.clone(), holes };
|
||||
}
|
||||
}
|
||||
}
|
||||
let skinned = skinned_controlled(plane, &kept, path, ctl, start, &bores, axial.as_ref().map(|(k, l, _)| (*k, *l)));
|
||||
let (body, st, size, closed) = match skinned {
|
||||
Ok(r) => r,
|
||||
Err(e) if !bores.is_empty() => {
|
||||
log::debug!(target: "smlib", "twisted sweep with its bores cut as cylinders: {e}; all skinned instead");
|
||||
skinned_controlled(plane, region, path, ctl, start, &[], None)?
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let (Some(first), Some(last)) = (st.first(), st.last()) else { return Err(KernelError::Invalid("sweep path".into())) };
|
||||
let s0 = first.at;
|
||||
let mid = st.get(st.len() / 2).copied().unwrap_or(*last);
|
||||
let inside = seeds(plane, &pieces, |q| mid.at + mid.apply(q - s0));
|
||||
let roles = if closed {
|
||||
// Round a closed path there are no caps, and the start holds seams only.
|
||||
roles(&body, &plane.offset(size * 10.0), None, &[], &inside, false)?
|
||||
} else {
|
||||
let end = Plane::new(last.at + last.apply(plane.origin - s0), last.apply(plane.x), last.apply(plane.y))
|
||||
.ok_or_else(|| KernelError::Invalid("the sweep's end section is degenerate".into()))?;
|
||||
roles(&body, plane, Some(&end), &seeds(plane, &pieces, |q| q), &inside, false)?
|
||||
};
|
||||
with_history(body, &roles, &pieces)
|
||||
}
|
||||
|
||||
/// The unit direction of a path of lines running straight along the profile plane's normal,
|
||||
/// and its length, when the sweep's laws only turn the profile about that line through the
|
||||
/// path's start (no end scale, no rails): each station is then the profile turned about the
|
||||
/// axis and moved along it.
|
||||
fn straight_axis(plane: &Plane, path: &[crate::PathSeg], ctl: &crate::SweepControl, start: Vec3) -> Option<(Vec3, f64)> {
|
||||
if ctl.scale != 1.0 || !ctl.rails.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let n = plane.normal().normalized()?;
|
||||
let mut at = start;
|
||||
let mut length = 0.0;
|
||||
let mut k: Option<Vec3> = None;
|
||||
for s in path {
|
||||
let crate::PathSeg::Line { a, b } = *s else { return None };
|
||||
let d = b - a;
|
||||
let t = d.normalized()?;
|
||||
let scale = 1.0 + a.dist(start) + b.dist(start);
|
||||
if a.dist(at) > 1e-9 * scale || t.dot(n).abs() < 1.0 - 1e-12 || k.is_some_and(|k| t.dot(k) < 1.0 - 1e-12) {
|
||||
return None;
|
||||
}
|
||||
k.get_or_insert(t);
|
||||
length += d.len();
|
||||
at = b;
|
||||
}
|
||||
k.filter(|_| length.is_finite() && length > 0.0).map(|k| (k, length))
|
||||
}
|
||||
|
||||
/// The centre and radius of `lp` when it is one circle (in one or several arcs) centred on the
|
||||
/// line through `start` along the unit `k`.
|
||||
fn circle_on_axis(plane: &Plane, lp: &solidcraft_geom::Loop2, start: Vec3, k: Vec3) -> Option<(Vec3, f64)> {
|
||||
let mut circle: Option<(Vec3, f64)> = None;
|
||||
let mut turn = 0.0;
|
||||
for s in &lp.segs {
|
||||
let Seg2::Arc { center, radius, sweep, .. } = *s else { return None };
|
||||
let c = plane.to_world(center);
|
||||
let tol = 1e-9 * (1.0 + radius.abs());
|
||||
let w = c - start;
|
||||
if !(radius.is_finite() && radius > 0.0) || (w - k * w.dot(k)).len() > tol {
|
||||
return None;
|
||||
}
|
||||
if let Some((c0, r0)) = circle
|
||||
&& (c0.dist(c) > tol || (r0 - radius).abs() > tol)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
circle.get_or_insert((c, radius));
|
||||
turn += sweep.abs();
|
||||
}
|
||||
circle.filter(|_| (turn - std::f64::consts::TAU).abs() <= 1e-9 * std::f64::consts::TAU)
|
||||
}
|
||||
|
||||
/// The controlled sweep of `region` skinned through its stations, with each of `bores` (a
|
||||
/// centre and radius on the straight path's axis `straight`: its direction and length) cut
|
||||
/// from it as an exact cylinder; the stations, the sweep's size and whether the path closes.
|
||||
fn skinned_controlled(
|
||||
plane: &Plane,
|
||||
region: &NumberedRegion,
|
||||
path: &[crate::PathSeg],
|
||||
ctl: &crate::SweepControl,
|
||||
start: Vec3,
|
||||
bores: &[(Vec3, f64)],
|
||||
straight: Option<(Vec3, f64)>,
|
||||
) -> Result<(Body, Vec<crate::sweep_path::Station>, f64, bool)> {
|
||||
let c = curves(plane, &pieces(region.loops()))?;
|
||||
// Each point of the profile turns with the path and the twist, and SMLib's skin is a cubic
|
||||
// interpolation through the stations with free ends, whose error near the ends falls only
|
||||
// as the square of the step: a square of reach 7.1 twisted a quarter turn over 40 mm is
|
||||
@@ -1004,8 +1129,7 @@ mod imp {
|
||||
let n = ((turn / step).ceil() as usize).max(rail_points.saturating_sub(1)).clamp(MIN_STATIONS, MAX_STATIONS);
|
||||
let (pts, tangents) = path_samples(path, n)?;
|
||||
let st = crate::sweep_path::controlled_stations(plane, &pts, &tangents, &[], ctl)?;
|
||||
let (Some(first), Some(last)) = (st.first(), st.last()) else { return Err(KernelError::Invalid("sweep path".into())) };
|
||||
let s0 = first.at;
|
||||
let s0 = st.first().ok_or_else(|| KernelError::Invalid("sweep path".into()))?.at;
|
||||
let size = pts.windows(2).map(|w| w[0].dist(w[1])).sum::<f64>() + region.outer.lp.signed_area().abs().sqrt();
|
||||
let closed = pts.last().is_some_and(|e| e.dist(start) <= 1e-7 * (1.0 + size));
|
||||
if closed {
|
||||
@@ -1020,19 +1144,20 @@ mod imp {
|
||||
Affine { m: [p(a), p(b), p(c)], t: p(s.at - s.apply(s0)) }
|
||||
})
|
||||
.collect();
|
||||
let brep = Brep::sweep_stations(&c, &maps, if closed { Ends::Joined } else { Ends::Capped }).map_err(|e| failed("sweep", e))?;
|
||||
let body = body_of(brep, "sweep")?;
|
||||
let mid = st.get(st.len() / 2).copied().unwrap_or(*last);
|
||||
let inside = seeds(plane, &pieces, |q| mid.at + mid.apply(q - s0));
|
||||
let roles = if closed {
|
||||
// Round a closed path there are no caps, and the start holds seams only.
|
||||
roles(&body, &plane.offset(size * 10.0), None, &[], &inside, false)?
|
||||
} else {
|
||||
let end = Plane::new(last.at + last.apply(plane.origin - s0), last.apply(plane.x), last.apply(plane.y))
|
||||
.ok_or_else(|| KernelError::Invalid("the sweep's end section is degenerate".into()))?;
|
||||
roles(&body, plane, Some(&end), &seeds(plane, &pieces, |q| q), &inside, false)?
|
||||
};
|
||||
with_history(body, &roles, &pieces)
|
||||
let mut brep = Brep::sweep_stations(&c, &maps, if closed { Ends::Joined } else { Ends::Capped }).map_err(|e| failed("sweep", e))?;
|
||||
if let (false, Some((k, length))) = (bores.is_empty(), straight) {
|
||||
// Past each end by a share of the sweep's size, so no face of the cut lies on a cap.
|
||||
let past = 0.25 * size + 1e-3;
|
||||
for (center, radius) in bores {
|
||||
let from = *center - k * past;
|
||||
let circle =
|
||||
Curve::Arc { center: p(from), axis: p(k), reference: p(plane.x), radius: *radius, start: 0.0, end: std::f64::consts::TAU };
|
||||
let bore =
|
||||
Brep::extrude(&[circle], p(k), length + 2.0 * past, 0.0, DraftCorner::default(), true).map_err(|e| failed("sweep bore", e))?;
|
||||
brep = combined(&brep, &bore, solidcraft_smlib::BooleanOp::Subtract, "sweep bore")?;
|
||||
}
|
||||
}
|
||||
Ok((body_of(brep, "sweep")?, st, size, closed))
|
||||
}
|
||||
|
||||
/// `x` moved by `a`.
|
||||
|
||||
@@ -287,3 +287,65 @@ fn a_sweep_follows_a_free_form_path_as_a_curve() {
|
||||
let b = smlib_sweeps::sweep_controlled(&Plane::XY, &numbered(&sq), &path, &ctl).unwrap();
|
||||
close(volume(&b), 16.0 * 46.220575543, 1e-3, "twisted square along a Bézier");
|
||||
}
|
||||
|
||||
/// A ring about the origin: `outer` radius with a hole of radius `bore`.
|
||||
fn ring(outer: f64, bore: f64) -> Region2 {
|
||||
Region2 { outer: Loop2::circle(Vec2::ZERO, outer), holes: vec![Loop2::circle(Vec2::ZERO, bore)] }
|
||||
}
|
||||
|
||||
/// The faces of `b` on a cylinder about the z axis of radius `r`.
|
||||
fn z_cylinders(b: &Body, r: f64) -> usize {
|
||||
(0..b.face_count())
|
||||
.filter(|&f| match crate::face_surface(b, f) {
|
||||
Some(crate::FaceSurface::Cylinder { origin, axis, radius }) => {
|
||||
(radius - r).abs() <= 1e-9 && axis.cross(Vec3::Z).len() <= 1e-12 && origin.x.hypot(origin.y) <= 1e-9
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_twisted_sweep_builds_circles_about_its_path_as_exact_cylinders() {
|
||||
// solidcore#2184: a tube r 2 / 1.5 extruded over z -2..3, joined with a tube r 2.5 / 1.5 swept
|
||||
// 1.25 up z with a twist. The twist turns each circle onto itself, so both of the swept tube's
|
||||
// walls are the exact cylinders, and its bore lies on the extruded tube's: one body,
|
||||
// pi (1.75 x 5 + 4 x 1.25 - 1.75 x 1.25) = 11.5625 pi. Skinned, the bore stayed within 4e-9
|
||||
// of the cylinder and the join failed at 16, 20 and 30 degrees.
|
||||
let a = crate::extrude(&Plane::XY, &[ring(2.0, 1.5)], -2.0, 3.0).unwrap().pop().unwrap();
|
||||
let up = [crate::PathSeg::Line { a: Vec3::ZERO, b: Vec3::new(0.0, 0.0, 1.25) }];
|
||||
let want = PI * (1.75 * 5.0 + 4.0 * 1.25 - 1.75 * 1.25);
|
||||
for deg in [10.0f64, 16.0, 18.0, 20.0, 30.0] {
|
||||
let ctl = crate::SweepControl { twist: deg.to_radians(), ..Default::default() };
|
||||
let b = crate::sweep_with(&Plane::XY, &ring(2.5, 1.5), &up, &ctl).unwrap();
|
||||
assert_eq!((z_cylinders(&b, 1.5), z_cylinders(&b, 2.5)), (1, 1), "{deg} deg: the walls are cylinders");
|
||||
let mut want_b = vec![cap(false), cap(true), profile(0), profile(1)];
|
||||
want_b.sort();
|
||||
assert_eq!(origins(&b), want_b, "{deg} deg");
|
||||
let joined = crate::boolean(&a, &b, crate::BoolOp::Union).unwrap().unwrap();
|
||||
assert_eq!((joined.shell_count(), joined.validity()), (1, Vec::<String>::new()), "{deg} deg");
|
||||
close(volume(&joined), want, 1e-8, &format!("{deg} deg join"));
|
||||
assert!(z_cylinders(&joined, 1.5) >= 1, "{deg} deg: the bore is a cylinder");
|
||||
}
|
||||
// A hexagon (circumradius 2.5, across flats 4.33, clear of the extruded tube's r 2) with the
|
||||
// same bore, twisted 30 degrees: the hexagon's walls are skinned, the bore cut as the exact
|
||||
// cylinder and named by its circle. The join is A + B less A's ring over 1.25.
|
||||
let hex =
|
||||
Loop2::polygon(&(0..6).map(|i| Vec2::new(2.5 * (f64::from(i) * PI / 3.0).cos(), 2.5 * (f64::from(i) * PI / 3.0).sin())).collect::<Vec<_>>());
|
||||
let hexed = Region2 { outer: hex, holes: vec![Loop2::circle(Vec2::ZERO, 1.5)] };
|
||||
let ctl = crate::SweepControl { twist: 30f64.to_radians(), ..Default::default() };
|
||||
let b = crate::sweep_with(&Plane::XY, &hexed, &up, &ctl).unwrap();
|
||||
let hex_area = 1.5 * 3f64.sqrt() * 6.25;
|
||||
close(volume(&b), (hex_area - PI * 2.25) * 1.25, 1e-5, "twisted hexagon");
|
||||
assert_eq!(z_cylinders(&b, 1.5), 1, "the bore is a cylinder");
|
||||
let o = origins(&b);
|
||||
assert!(o.contains(&cap(false)) && o.contains(&cap(true)) && (0..7).all(|k| o.contains(&profile(k))), "{o:?}");
|
||||
let joined = crate::boolean(&a, &b, crate::BoolOp::Union).unwrap().unwrap();
|
||||
assert_eq!((joined.shell_count(), joined.validity()), (1, Vec::<String>::new()));
|
||||
close(volume(&joined), PI * 1.75 * 5.0 + (hex_area - PI * 2.25) * 1.25 - PI * 1.75 * 1.25, 1e-5, "hexagon join");
|
||||
// A circle off the path's line is skinned as before.
|
||||
let off = region(Loop2::circle(Vec2::new(0.5, 0.0), 1.0));
|
||||
let b = crate::sweep_with(&Plane::XY, &off, &up, &ctl).unwrap();
|
||||
assert!((0..b.face_count()).all(|f| !matches!(crate::face_surface(&b, f), Some(crate::FaceSurface::Cylinder { .. }))));
|
||||
close(volume(&b), PI * 1.25, 1e-6, "twisted circle off the axis");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user