//! What a picked item is, exactly (#1392): circles, arcs and cylinders (round items with an //! axis), planes, straight lines and points, from the B-rep's exact curves and surfaces or the //! sketch's own geometry. Round items report their diameter and radius, and a pair with a //! round item measures from its centre or axis, with the closest and farthest distances too. use serde_json::{Value, json}; use solidcraft_geom::Vec3; use std::f64::consts::{PI, TAU}; /// The exact shape of a picked item. #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) enum Shape { /// A circle, an arc or a cylinder. `center` lies on the axis: a circle's or arc's centre, /// or for a cylinder the point of the axis level with the middle of the face. `lo..hi` is /// the extent along `axis` from `center` (0..0 for a planar circle). `angle` is the span /// around the axis (2π all the way round). Round { center: Vec3, axis: Vec3, radius: f64, angle: f64, planar: bool, lo: f64, hi: f64, }, Plane { point: Vec3, normal: Vec3, }, Line { a: Vec3, b: Vec3, }, Point(Vec3), Other, } /// Within this of a whole turn counts as all the way round (a tessellated face's coverage). const FULL_TURN: f64 = TAU - 1e-3; impl Shape { pub(crate) fn full(&self) -> bool { matches!(self, Shape::Round { angle, .. } if *angle >= FULL_TURN) } } /// Diameter, radius and length (circumference or arc length, with the arc's angle) of a round /// item, added to its description. Ø first, then R, then the length. pub(crate) fn add_round(v: &mut Value, shape: &Shape) { let Shape::Round { center, axis, radius, angle, planar, .. } = *shape else { return }; v["diameter"] = json!(2.0 * radius); v["radius"] = json!(radius); if angle >= FULL_TURN { v["circumference"] = json!(TAU * radius); } else { v["angle_deg"] = json!(angle.to_degrees()); v["arc_length"] = json!(radius * angle); } v["center"] = json!(center); v["axis"] = json!(axis); v["round"] = json!(if !planar { "cylinder" } else if angle >= FULL_TURN { "circle" } else { "arc" }); } /// Cylinder `axis` through `origin` of radius `radius` over the points of its face: the /// round shape, centred level with the middle of the face, with its extent and coverage. pub(crate) fn cylinder(origin: Vec3, axis: Vec3, radius: f64, pts: &[Vec3]) -> Option { let axis = axis.normalized()?; if pts.is_empty() || !(radius.is_finite() && radius > 0.0) { return None; } let ts: Vec = pts.iter().map(|p| (*p - origin).dot(axis)).collect(); let (tmin, tmax) = ts.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), t| (a.min(*t), b.max(*t))); let mid = (tmin + tmax) * 0.5; let center = origin + axis * mid; Some(Shape::Round { center, axis, radius, angle: coverage(center, axis, pts), planar: false, lo: tmin - mid, hi: tmax - mid }) } /// The angle around the axis the points cover: a whole turn less the widest gap between them. pub(crate) fn coverage(center: Vec3, axis: Vec3, pts: &[Vec3]) -> f64 { let x = axis.any_perp(); let y = axis.cross(x); let mut angles: Vec = pts .iter() .filter_map(|p| { let d = *p - center; let r = d - axis * d.dot(axis); (r.len() > 1e-12).then(|| r.dot(y).atan2(r.dot(x))) }) .collect(); if angles.len() < 2 { return 0.0; } angles.sort_by(f64::total_cmp); let wrap = angles.first().copied().unwrap_or(0.0) + TAU - angles.last().copied().unwrap_or(0.0); let widest = angles.windows(2).map(|w| w[1] - w[0]).fold(wrap, f64::max); // Tessellation leaves gaps of a few degrees on a whole cylinder: those still count. if widest < 0.35 { TAU } else { TAU - widest } } /// The unit direction a point leaves the axis at, towards the middle of the face's points. fn bulge(center: Vec3, axis: Vec3, pts: &[Vec3]) -> Option { let sum = pts.iter().fold(Vec3::ZERO, |acc, p| { let d = *p - center; acc + (d - axis * d.dot(axis)).normalized().unwrap_or(Vec3::ZERO) }); sum.normalized() } /// Closest points of two infinite lines (a point and a unit direction each). fn line_line(p1: Vec3, d1: Vec3, p2: Vec3, d2: Vec3) -> (Vec3, Vec3) { let w = p1 - p2; let b = d1.dot(d2); let denom = 1.0 - b * b; if denom < 1e-12 { return (p1, p2 + d2 * w.dot(d2)); } let (d, e) = (d1.dot(w), d2.dot(w)); let s = (b * e - d) / denom; let t = (e - b * d) / denom; (p1 + d1 * s, p2 + d2 * t) } fn parallel(a: Vec3, b: Vec3) -> bool { a.cross(b).len() < 1e-6 } /// A measurement from a round item's centre or axis. #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct Centre { pub distance: f64, pub from: Vec3, pub to: Vec3, /// Closest and farthest distances worked out exactly (`None`: measure them on the mesh). pub min: Option, pub max: Option, } /// Distance between two non-negative in-plane circles' rims, closest and farthest, with /// centres `d` apart. fn rims(d: f64, ra: f64, rb: f64) -> (f64, f64) { let near = if d >= ra + rb { d - ra - rb } else if d <= (ra - rb).abs() { (ra - rb).abs() - d } else { 0.0 }; (near, d + ra + rb) } /// Centre-to-centre (axis to axis, axis to plane, line or point) between two items, when one /// of them is round. `None` for pairs without a round item and where an axis meets the other /// item at an angle that gives no single distance (a cylinder crossing a plane). pub(crate) fn centre(a: &Shape, b: &Shape) -> Option { match (a, b) { (Shape::Round { .. }, _) => from_round(a, b), (_, Shape::Round { .. }) => from_round(b, a).map(|c| Centre { from: c.to, to: c.from, ..c }), _ => None, } } fn from_round(a: &Shape, b: &Shape) -> Option { let Shape::Round { center: ca, axis: na, radius: ra, planar: pa, lo: loa, hi: hia, .. } = *a else { return None }; let full_a = a.full(); let c = match *b { Shape::Round { center: cb, axis: nb, radius: rb, lo: lob, hi: hib, .. } => { if parallel(na, nb) { let w = cb - ca; let along = w.dot(na); // Drawn level with A's centre: on B's axis, the foot of A's centre. let foot = cb - na * along; let d = (w - na * along).len(); let (mut min, mut max) = (None, None); if full_a && b.full() { // B's extent along A's axis. let s = if na.dot(nb) >= 0.0 { 1.0 } else { -1.0 }; let (b0, b1) = (along + lob * s, along + hib * s); let (b0, b1) = (b0.min(b1), b0.max(b1)); let gap = (loa.max(b0) - hia.min(b1)).max(0.0); let reach = (hia - b0).abs().max((b1 - loa).abs()); let (near, far) = rims(d, ra, rb); min = Some(near.hypot(gap)); max = Some(far.hypot(reach)); } Centre { distance: d, from: ca, to: foot, min, max } } else { let (p, q) = line_line(ca, na, cb, nb); Centre { distance: p.dist(q), from: p, to: q, min: None, max: None } } } Shape::Plane { point, normal } => { let n = normal.normalized()?; let h = (ca - point).dot(n); if na.dot(n).abs() < 1e-6 { // Axis along the plane: axis to plane, and the rim's nearest and farthest. let d = h.abs(); let (min, max) = if full_a { (Some((d - ra).max(0.0)), Some(d + ra)) } else { (None, None) }; Centre { distance: d, from: ca, to: ca - n * h, min: if d >= ra { min } else { None }, max } } else if pa { // A circle's centre to the plane. Centre { distance: h.abs(), from: ca, to: ca - n * h, min: None, max: None } } else { return None; } } Shape::Line { a: la, b: lb } => { let dir = (lb - la).normalized()?; let (p, q) = line_line(ca, na, la, dir); let d = p.dist(q); let across = dir.dot(na).abs() < 1e-6; let min = (full_a && across && d >= ra).then_some(d - ra); Centre { distance: d, from: p, to: q, min, max: None } } Shape::Point(p) => { if pa { let d = p.dist(ca); let in_plane = (p - ca).dot(na).abs() < 1e-6 * (1.0 + d); let (min, max) = if full_a && in_plane { (Some((d - ra).abs()), Some(d + ra)) } else { (None, None) }; Centre { distance: d, from: ca, to: p, min, max } } else { let w = p - ca; let foot = ca + na * w.dot(na); Centre { distance: p.dist(foot), from: foot, to: p, min: None, max: None } } } Shape::Other => return None, }; (c.distance.is_finite() && c.from.is_finite() && c.to.is_finite()).then_some(c) } /// A slot's sizes: width (the ends' diameter), the distance between the ends' centres and the /// overall length, with the two centres. #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct Slot { pub width: f64, pub centres: (Vec3, Vec3), } impl Slot { pub(crate) fn json(&self) -> Value { let c2c = self.centres.0.dist(self.centres.1); json!({"width": self.width, "center_distance": c2c, "overall_length": c2c + self.width, "centers": [self.centres.0, self.centres.1]}) } } /// A slot's outline in its plane: two half-circle ends of one radius joined by two straight /// sides. `arcs` are (centre, radius, swept angle) with pieces of one end already merged, /// `lines` the straight pieces (2D, in the sketch plane). pub(crate) fn slot_outline( arcs: &[(solidcraft_geom::Vec2, f64, f64)], lines: &[(solidcraft_geom::Vec2, solidcraft_geom::Vec2)], ) -> Option<(solidcraft_geom::Vec2, solidcraft_geom::Vec2, f64)> { let [(c1, r1, s1), (c2, r2, s2)] = arcs else { return None }; let tol = 1e-6 * (1.0 + r1.abs() + c1.dist(*c2)); if (r1 - r2).abs() > tol || (s1.abs() - PI).abs() > 1e-4 || (s2.abs() - PI).abs() > 1e-4 || c1.dist(*c2) <= tol || lines.is_empty() { return None; } let along = (*c2 - *c1).normalized()?; // Every straight piece runs along the slot, `r` to one side of the centre line. let side = |p: solidcraft_geom::Vec2| (p - *c1).cross(along); let ok = lines.iter().all(|(a, b)| { let d = *b - *a; d.cross(along).abs() <= tol * (1.0 + d.len()) && (side(*a).abs() - r1).abs() <= tol && (side(*b).abs() - r1).abs() <= tol }); let both = lines.iter().any(|(a, _)| side(*a) > 0.0) && lines.iter().any(|(a, _)| side(*a) < 0.0); (ok && both).then_some((*c1, *c2, *r1)) } /// A slot cut or boss on a solid, found from one of its half-cylinder ends: another half /// cylinder of the same radius on a parallel axis, bulging the other way, and two planes along /// the slot `r` either side of its centre line. `faces` gives each face's surface and points. pub(crate) fn slot_from_faces(picked: &Shape, picked_pts: &[Vec3], faces: &[(solidcraft_kernel::FaceSurface, Vec)]) -> Option { use solidcraft_kernel::FaceSurface; let Shape::Round { center, axis, radius, angle, planar: false, .. } = *picked else { return None }; if (angle - PI).abs() > 0.3 { return None; } let tol = 1e-6 * (1.0 + radius) * 100.0; let out_a = bulge(center, axis, picked_pts)?; for (s, pts) in faces { let FaceSurface::Cylinder { origin, axis: ax, radius: r } = *s else { continue }; if (r - radius).abs() > tol || !parallel(ax, axis) { continue; } let Some(Shape::Round { center: cb, angle: ab, .. }) = cylinder(origin, ax, r, pts) else { continue }; let w = cb - center; let off = w - axis * w.dot(axis); let Some(along) = off.normalized() else { continue }; if (ab - PI).abs() > 0.3 || off.len() <= tol { continue; } let Some(out_b) = bulge(cb, axis, pts) else { continue }; // Each end bulges away from the other. if out_a.dot(along) > -0.5 || out_b.dot(along) < 0.5 { continue; } let across = axis.cross(along); let sides = faces.iter().filter(|(fs, _)| match *fs { FaceSurface::Plane { normal, point } => { parallel(normal, across) && ((point - center).dot(across).abs() - radius).abs() <= tol.max(radius * 1e-4) } _ => false, }); let (mut pos, mut neg) = (false, false); for (fs, _) in sides { if let FaceSurface::Plane { point, .. } = fs { if (*point - center).dot(across) > 0.0 { pos = true } else { neg = true } } } if pos && neg { // Both centres level with the picked end's middle. return Some(Slot { width: 2.0 * radius, centres: (center, center + off) }); } } None } #[cfg(test)] mod tests { use super::*; use solidcraft_geom::Vec2; fn circle(c: Vec3, r: f64) -> Shape { Shape::Round { center: c, axis: Vec3::Z, radius: r, angle: TAU, planar: true, lo: 0.0, hi: 0.0 } } #[test] fn two_circles_measure_centre_to_centre_with_rims() { let c = centre(&circle(Vec3::ZERO, 3.0), &circle(Vec3::new(30.0, 40.0, 0.0), 2.0)).unwrap(); assert!((c.distance - 50.0).abs() < 1e-12); assert!((c.min.unwrap() - 45.0).abs() < 1e-12); assert!((c.max.unwrap() - 55.0).abs() < 1e-12); } #[test] fn a_cylinder_to_a_side_plane_measures_from_its_axis() { let cyl = Shape::Round { center: Vec3::new(10.0, 15.0, 5.0), axis: Vec3::Z, radius: 2.5, angle: TAU, planar: false, lo: -5.0, hi: 5.0 }; let plane = Shape::Plane { point: Vec3::ZERO, normal: Vec3::new(-1.0, 0.0, 0.0) }; let c = centre(&cyl, &plane).unwrap(); assert!((c.distance - 10.0).abs() < 1e-12); assert!((c.min.unwrap() - 7.5).abs() < 1e-12 && (c.max.unwrap() - 12.5).abs() < 1e-12); // Swapped, the line runs the other way. let d = centre(&plane, &cyl).unwrap(); assert_eq!((d.from, d.to), (c.to, c.from)); // A cylinder crossing a plane has no single axis distance. assert!(centre(&cyl, &Shape::Plane { point: Vec3::ZERO, normal: Vec3::Z }).is_none()); } #[test] fn an_axis_to_a_crossing_edge_is_the_distance_between_the_lines() { let cyl = Shape::Round { center: Vec3::new(10.0, 15.0, 5.0), axis: Vec3::Z, radius: 2.5, angle: TAU, planar: false, lo: -5.0, hi: 5.0 }; let edge = Shape::Line { a: Vec3::new(0.0, 0.0, 10.0), b: Vec3::new(40.0, 0.0, 10.0) }; let c = centre(&cyl, &edge).unwrap(); assert!((c.distance - 15.0).abs() < 1e-12); assert!((c.min.unwrap() - 12.5).abs() < 1e-12); assert!(c.from.dist(Vec3::new(10.0, 15.0, 10.0)) < 1e-12 && c.to.dist(Vec3::new(10.0, 0.0, 10.0)) < 1e-12); } #[test] fn coverage_of_a_half_and_a_whole_ring() { let ring = |n: usize, span: f64| -> Vec { (0..=n).map(|i| Vec3::new((span * i as f64 / n as f64).cos(), (span * i as f64 / n as f64).sin(), 0.0)).collect() }; assert!((coverage(Vec3::ZERO, Vec3::Z, &ring(32, PI)) - PI).abs() < 1e-9); assert_eq!(coverage(Vec3::ZERO, Vec3::Z, &ring(32, TAU)), TAU); } #[test] fn a_slot_outline_and_not_a_slot() { let arcs = [(Vec2::new(0.0, 0.0), 3.0, PI), (Vec2::new(20.0, 0.0), 3.0, PI)]; let lines = [(Vec2::new(0.0, -3.0), Vec2::new(20.0, -3.0)), (Vec2::new(20.0, 3.0), Vec2::new(0.0, 3.0))]; let (a, b, r) = slot_outline(&arcs, &lines).unwrap(); assert_eq!((a.dist(b), r), (20.0, 3.0)); let skew = [(Vec2::new(0.0, -3.0), Vec2::new(20.0, -2.0)), (Vec2::new(20.0, 3.0), Vec2::new(0.0, 3.0))]; assert!(slot_outline(&arcs, &skew).is_none()); assert!(slot_outline(&[(Vec2::new(0.0, 0.0), 3.0, PI), (Vec2::new(20.0, 0.0), 4.0, PI)], &lines).is_none()); } }