diff --git a/crates/smlib/src/core/sweep.rs b/crates/smlib/src/core/sweep.rs index 9540bbc859..b039eca9fc 100644 --- a/crates/smlib/src/core/sweep.rs +++ b/crates/smlib/src/core/sweep.rs @@ -1,6 +1,7 @@ //! Extrusions, revolutions, sweeps, lofts and planar faces on solidcore: the C API's logic round //! SM_API's sweeps, ported from the fork's `capi/src/smlib_sweeps.cpp` (`coord_ok`, `unit`, -//! `line`, `arc`, `nurbs`, `parse`, `finish`, `one_loop`, `straight`, `circular`, `joined`, +//! `line`, `arc`, `nurbs`, `parse`, `finish`, `one_loop`, `FlatArc`, `one_circle`, `angle_on`, +//! `one_wall_per_circle`, `straight`, `circular`, `joined`, //! `put_motion`, `turned`, `Rigid`, `Segment`, `segmented`, `carried`, `piecewise`, `maps_ok`, `placed`, `put_curve`, and the entry points //! `smlib_planar_faces`, `smlib_extrude`, `smlib_revolve`, `smlib_sweep_placed`, `smlib_loft`, //! `smlib_loft_to_point`, `smlib_sweep_stations`, `smlib_project_curves`, `smlib_face_section`, @@ -26,7 +27,7 @@ use solidcore::curve::ellipse::Ellipse; use solidcore::curve::line::Line; use solidcore::geom::axis2_placement::Axis2Placement; use solidcore::geom::extent1d::Extent1d; -use solidcore::geom::tol::USE_DEFAULT; +use solidcore::geom::tol::{SmallTopoType, USE_DEFAULT, context_zone_tol_3d}; use solidcore::geom::vector3d::{Point3, Vec3}; use solidcore::heal::stitch::p1::api_stitch_into_solid; use solidcore::kernel::Kernel; @@ -262,6 +263,174 @@ fn one_loop(k: &Kernel, curves: &[Curve], tol: f64) -> solidcore::Result { Ok(matches!((first, prev), (Some(f), Some(p)) if (p - f).length() <= tol)) } +/// `FlatArc`: an arc of a curve list as numbers, its frame, radius and angles. +#[derive(Clone, Copy)] +struct FlatArc { + centre: Vec3, + axis: Vec3, + reference: Vec3, + radius: f64, + start: f64, + sweep: f64, +} + +impl FlatArc { + /// `flat_arc`: the arc's numbers, its axis and reference made unit (`parse` has checked + /// that they can be); `None` for a curve that is no arc. + fn of(c: &Profile) -> Option { + let Profile::Arc { center, axis, reference, radius, start, end } = c else { return None }; + Some(FlatArc { + centre: v3(*center), + axis: unit(*axis).unwrap_or_else(|| v3(*axis)), + reference: unit(*reference).unwrap_or_else(|| v3(*reference)), + radius: *radius, + start: *start, + sweep: end - start, + }) + } + + /// The point at angle `t` (radians from the reference, counter-clockwise about the axis). + fn at(&self, t: f64) -> Point3 { + let y = self.axis.cross(self.reference); + self.centre + self.reference * (self.radius * t.cos()) + y * (self.radius * t.sin()) + } + + /// `angle_on`: the angle of `p` on this arc's circle, in its frame (radians, in [0, 2π)). + fn angle_on(&self, p: Point3) -> f64 { + let d = p - self.centre; + let y = self.axis.cross(self.reference); + let t = d.dot(y).atan2(d.dot(self.reference)); + if t < 0.0 { t + TAU } else { t } + } +} + +/// `one_circle`: whether arcs `a` and `b` lie on one circle to `tol`: centres and radii within +/// it, and the axes alike (the same way round) to within it at the rim. +fn one_circle(a: &FlatArc, b: &FlatArc, tol: f64) -> bool { + (a.centre - b.centre).length() <= tol + && (a.radius - b.radius).abs() <= tol + && a.axis.dot(b.axis) > 0.0 + && a.axis.cross(b.axis).length() * a.radius.max(b.radius) <= tol +} + +/// `one_wall_per_circle` (tangency-labs/smlib 7dee48f): the profile `curves` (parsed from +/// `given`) with each run of consecutive arcs on one circle within a loop made one arc, so that +/// the run's wall is one face as a single arc's is: a circle drawn in pieces (split where another +/// sketch curve ends on it) sweeps the one cylinder it lies on, not a face per piece meeting the +/// next along a line that is no edge of the shape. A run that is its whole loop is the full +/// circle, swept as a plain circle is: one periodic face, its seam the one edge it uses twice. +/// The seam goes where a plain circle has it: when the arcs are pieces of one drawn circle (they +/// share its frame, the reference direction their angles are measured from), at that circle's +/// own start, angle 0 of that frame; otherwise at the middle of the run's longest arc (the first +/// of equal ones, in profile order). `tol` is the model's zone tolerance, within which +/// consecutive curves already meet. +fn one_wall_per_circle(k: &Kernel, given: &[Profile], curves: Vec, tol: f64) -> solidcore::Result>> { + let n = curves.len(); + if given.len() != n { + return Ok(Err(Error::Argument)); + } + let arcs: Vec> = given.iter().map(FlatArc::of).collect(); + let (mut head, mut tail) = (Vec::with_capacity(n), Vec::with_capacity(n)); + for c in &curves { + let (a, b) = ends(k, c)?; + head.push(a); + tail.push(b); + } + // Whether the end of curve `i` meets the start of curve `j`; whether arc `j` goes on from + // arc `i`, on its circle. + let meets = |i: usize, j: usize| matches!((tail.get(i), head.get(j)), (Some(t), Some(h)) if (*t - *h).length() <= tol); + let arc_of = |i: usize| arcs.get(i).copied().flatten(); + let joins = |i: usize, j: usize| match (arc_of(i), arc_of(j)) { + (Some(a), Some(b)) => meets(i, j) && one_circle(&a, &b, tol), + _ => false, + }; + let mut taken: Vec> = curves.into_iter().map(Some).collect(); + let mut take = |i: usize| taken.get_mut(i).and_then(Option::take).ok_or(Error::Argument); + let mut out = Vec::with_capacity(n); + let mut first = 0; + while first < n { + // The loop from `first`: its curves run end to end until one ends at its start. + let mut last = first; + while last + 1 < n && !meets(last, first) && meets(last, last + 1) { + last += 1; + } + let len = last - first + 1; + let closed = meets(last, first); + // Whether two of the loop's arcs go on on one circle. + let mut any = false; + let mut j = 0; + while j + 1 < len || (closed && len > 1 && j < len) { + any = any || joins(first + j, first + (j + 1) % len); + j += 1; + } + if !any { + for i in first..=last { + match take(i) { + Ok(c) => out.push(c), + Err(e) => return Ok(Err(e)), + } + } + first = last + 1; + continue; + } + // A run starts where its curve does not go on from the one before it round the loop. + let mut begin = 0; + if closed { + while begin < len && joins(first + (begin + len - 1) % len, first + begin) { + begin += 1; + } + } + if begin == len { + // The whole loop is arcs of one circle: the full circle, its seam at the drawn + // circle's start when the arcs share its frame, else at the middle of the longest arc. + let Some(a) = arc_of(first) else { return Ok(Err(Error::Argument)) }; + let mut one_frame = true; + let mut longest = a; + for i in first..=last { + let Some(b) = arc_of(i) else { return Ok(Err(Error::Argument)) }; + one_frame = one_frame && (b.reference - a.reference).length() * a.radius <= tol; + if b.sweep > longest.sweep { + longest = b; + } + } + let seam = if one_frame { 0.0 } else { a.angle_on(longest.at(longest.start + 0.5 * longest.sweep)) }; + let p = |v: Vec3| [v.x, v.y, v.z]; + match arc(k, p(a.centre), p(a.axis), p(a.reference), a.radius, seam, seam + TAU) { + Ok(c) => out.push(c), + Err(e) => return Ok(Err(e)), + } + } else { + let mut kk = 0; + while kk < len { + let i = first + (begin + kk) % len; + // The arcs i, i + 1, ... round the loop that go on on its circle. + let mut m = 1; + while kk + m < len && joins(first + (begin + kk + m - 1) % len, first + (begin + kk + m) % len) { + m += 1; + } + if m == 1 { + match take(i) { + Ok(c) => out.push(c), + Err(e) => return Ok(Err(e)), + } + } else { + let (Some(a), Some(&h)) = (arc_of(i), head.get(i)) else { return Ok(Err(Error::Argument)) }; + let sweep: f64 = (0..m).filter_map(|j| arc_of(first + (begin + kk + j) % len)).map(|b| b.sweep).sum(); + let start = a.angle_on(h); + let p = |v: Vec3| [v.x, v.y, v.z]; + match arc(k, p(a.centre), p(a.axis), p(a.reference), a.radius, start, start + sweep) { + Ok(c) => out.push(c), + Err(e) => return Ok(Err(e)), + } + } + kk += m; + } + } + first = last + 1; + } + Ok(Ok(out)) +} + /// Runs a call that makes a new body: the kernel locked, its own status as the capi passes it. fn making(call: &'static str, f: impl FnOnce(&mut Kernel) -> solidcore::Result>) -> Result { run(call, f)?.map(Body::new) @@ -307,13 +476,20 @@ pub(crate) fn extrude(profile: &[Profile], direction: [f64; 3], distance: f64, d Ok(c) => c, Err(e) => return Ok(Err(e)), }; - let refs: Vec> = curves.iter().map(Some).collect(); if draft == 0.0 { + // The fork's capi (7dee48f): arcs of a loop on one circle sweep one wall. + let tol = context_zone_tol_3d(k, None, SmallTopoType::NormalSize); + let walls = match one_wall_per_circle(k, profile, curves, tol)? { + Ok(w) => w, + Err(e) => return Ok(Err(e)), + }; + let refs: Vec> = walls.iter().map(Some).collect(); return match create_linear_sweep(k, &refs, dir, distance, cap) { Ok(r) => finish(r, cap), Err(e) => Ok(Err(status(e))), }; } + let refs: Vec> = curves.iter().map(Some).collect(); // SMLib's draft extrusion takes a single chain of curves, square to the extrusion: the // height's sign picks the side, matched to `direction` here. if !one_loop(k, &curves, 1e-6)? { diff --git a/crates/smlib/src/core/sweep_tests.rs b/crates/smlib/src/core/sweep_tests.rs index b3b2a7ce26..b7fd4a3ae6 100644 --- a/crates/smlib/src/core/sweep_tests.rs +++ b/crates/smlib/src/core/sweep_tests.rs @@ -40,6 +40,26 @@ fn circle(center: [f64; 3], axis: [f64; 3], reference: [f64; 3], radius: f64) -> Curve::Arc { center, axis, reference, radius, start: 0.0, end: TAU } } +/// An arc of radius 5 about the z axis from `reference`, from `start` to `end`. +fn arc5(reference: [f64; 3], start: f64, end: f64) -> Curve { + Curve::Arc { center: [0.0; 3], axis: Z, reference, radius: 5.0, start, end } +} + +/// The circle of radius 5 about the z axis as two half arcs in one frame. +fn halves() -> Vec { + vec![arc5([1.0, 0.0, 0.0], 0.0, PI), arc5([1.0, 0.0, 0.0], PI, TAU)] +} + +/// The circle of radius 5 about the z axis as a quarter from +x and three quarters from +y. +fn two_frames() -> Vec { + vec![arc5([1.0, 0.0, 0.0], 0.0, FRAC_PI_2), arc5([0.0, 1.0, 0.0], 0.0, 3.0 * FRAC_PI_2)] +} + +/// The upper half disc of radius 5 about the z axis, its arc in two quarters. +fn half_disc() -> Vec { + vec![arc5([1.0, 0.0, 0.0], 0.0, FRAC_PI_2), arc5([1.0, 0.0, 0.0], FRAC_PI_2, PI), line([-5.0, 0.0, 0.0], [5.0, 0.0, 0.0])] +} + /// The rectangle from x = 5 to 8 and z = 0 to 4 in the plane y = 0: area 12, centroid at x 6.5. fn ring_section() -> Vec { polygon(&[[5.0, 0.0, 0.0], [8.0, 0.0, 0.0], [8.0, 0.0, 4.0], [5.0, 0.0, 4.0]]) @@ -106,6 +126,15 @@ fn case(name: &str) -> Case { "extrude_rectangle" => c(Op::Extrude(rect, Z, 10.0, 0.0, DraftCorner::default(), true), Expect::Volume(6000.0)), // V = π r² h. "extrude_circle" => c(Op::Extrude(disc(), Z, 10.0, 0.0, DraftCorner::default(), true), Expect::Volume(250.0 * PI)), + // A circle drawn in two halves (split where another sketch curve ends on it), the + // pieces in the circle's own frame: one wall, as the whole circle sweeps (smlib 7dee48f). + "extrude_circle_in_two_halves" => c(Op::Extrude(halves(), Z, 10.0, 0.0, DraftCorner::default(), true), Expect::Volume(250.0 * PI)), + // The same circle as a quarter and three quarters each in its own frame: one wall still, + // its seam in the middle of the longer piece. + "extrude_circle_in_two_frames" => c(Op::Extrude(two_frames(), Z, 10.0, 0.0, DraftCorner::default(), true), Expect::Volume(250.0 * PI)), + // A half disc whose arc is drawn in two quarters, closed by its diameter: the quarters + // sweep one wall. V = π r² h / 2. + "extrude_half_disc_split_arc" => c(Op::Extrude(half_disc(), Z, 10.0, 0.0, DraftCorner::default(), true), Expect::Volume(125.0 * PI)), // Leaning in by θ, the walls' offsets overlap at the corners and are trimmed where they // cross, under every rule: the frustum from side 20 to 20 - 2 h tanθ. "extrude_square_drafted_round" => { @@ -404,6 +433,9 @@ cases!( planar_faces_with_a_hole, extrude_rectangle, extrude_circle, + extrude_circle_in_two_halves, + extrude_circle_in_two_frames, + extrude_half_disc_split_arc[ignore = "solidcore sweeps a partial arc to a surface of extrusion (exported as a rational spline), the C++ kernel to a cylinder; a single half arc does the same"], extrude_square_drafted_round, extrude_square_drafted_curvature, extrude_circle_drafted, @@ -423,6 +455,55 @@ cases!( screw_sweep_radial_taper, ); +/// Arcs of a profile on one circle sweep one wall face (tangency-labs/smlib 7dee48f): a circle +/// in two pieces extrudes to the faces, edges and vertices of the whole circle (two caps and one +/// cylinder: 3 faces, 3 edges with the seam, 2 vertices) and the area 2πr² + 2πrh; a half disc +/// whose arc is in two quarters to its diameter's plane wall and one cylinder (4 faces, 6 edges, +/// 4 vertices) and the area πr² + πrh + 2rh. On solidcore, and with SMLib built on the C++ kernel. +#[test] +fn arcs_on_one_circle_sweep_one_wall() { + let cases = [ + ("extrude_circle_in_two_halves", [3, 3, 2], 50.0 * PI + 100.0 * PI, true), + ("extrude_circle_in_two_frames", [3, 3, 2], 50.0 * PI + 100.0 * PI, true), + // solidcore sweeps a partial arc, merged or drawn whole, to a surface of extrusion (a + // rational spline in the record) where the C++ kernel makes a cylinder: one wall still. + ("extrude_half_disc_split_arc", [4, 6, 4], 25.0 * PI + 50.0 * PI + 100.0, false), + ]; + let walls = |kinds: &BTreeMap| kinds.iter().filter(|(k, _)| !k.starts_with("plane")).map(|(_, n)| *n).sum::(); + for (name, want_counts, want_area, cylinder_on_core) in cases { + let (counts_got, kinds_got, area_got) = timed(name, move || { + let body = on_core(&case(name).op).unwrap_or_else(|e| panic!("{name}: {e}")); + let (rec, _) = native(&to_arrays(&body).unwrap()).unwrap(); + (counts(&body).unwrap(), kinds(&rec), area(&body, 1e-8).unwrap()) + }); + assert_eq!(counts_got, want_counts, "{name}: solidcore faces, edges, vertices"); + assert_eq!(walls(&kinds_got), 1, "{name}: solidcore curved walls {kinds_got:?}"); + if cylinder_on_core { + assert_eq!(kinds_got.get("cylinder"), Some(&1), "{name}: solidcore walls {kinds_got:?}"); + } + assert!(rel(area_got, want_area) < 1e-9, "{name}: solidcore area {area_got} vs analytic {want_area}"); + #[cfg(feature = "native")] + { + use crate::{Brep, ffi}; + crate::sweeps::ON_CPP.with(|c| c.set(true)); + let r = match case(name).op { + Op::Extrude(p, d, dist, draft, corner, cap) => Brep::extrude(&p, d, dist, draft, corner, cap), + op => panic!("{name}: {op:?}"), + }; + crate::sweeps::ON_CPP.with(|c| c.set(false)); + let b = r.unwrap_or_else(|e| panic!("{name}: C++: {e}")); + let h = b.cpp().unwrap(); + let n = ffi::counts(h).unwrap().map(|x| usize::try_from(x).unwrap()); + let (rec, _) = native(&b.cpp_arrays().unwrap()).unwrap(); + assert_eq!(n, want_counts, "{name}: C++ faces, edges, vertices"); + assert_eq!(walls(&kinds(&rec)), 1, "{name}: C++ curved walls {:?}", kinds(&rec)); + assert_eq!(kinds(&rec).get("cylinder"), Some(&1), "{name}: C++ walls {:?}", kinds(&rec)); + let a = ffi::measure::area(h, 1e-8).unwrap(); + assert!(rel(a, want_area) < 1e-9, "{name}: C++ area {a} vs analytic {want_area}"); + } + } +} + fn near(a: [f64; 3], b: [f64; 3]) -> bool { a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-9) }