Files
OneCAD/crates/engine/src/cmd/measure_shape.rs
T
2026-10-15 21:27:29 -04:00

486 lines
22 KiB
Rust

//! 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<Shape> {
let axis = axis.normalized()?;
if pts.is_empty() || !(radius.is_finite() && radius > 0.0) {
return None;
}
let ts: Vec<f64> = 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<f64> = 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<Vec3> {
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<f64>,
pub max: Option<f64>,
}
/// 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<Centre> {
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<Centre> {
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, planar: pb, lo: lob, hi: hib, .. } => {
// A picked circle/arc has a fixed centre; only a cylindrical face has an
// infinite axis. Do not project a rim's centre onto an imaginary extension.
let (from, to) = match (pa, pb) {
(true, true) => (ca, cb),
(true, false) => (ca, cb + nb * (ca - cb).dot(nb)),
(false, true) => (ca + na * (cb - ca).dot(na), cb),
(false, false) => line_line(ca, na, cb, nb),
};
let (mut min, mut max) = (None, None);
if parallel(na, nb) {
let w = cb - ca;
let along = w.dot(na);
let d = (w - na * along).len();
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: from.dist(to), from, to, min, max }
}
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 across = dir.dot(na).abs() < 1e-6;
if pa {
// A circle/arc has a fixed centre. Project that point onto the picked
// finite edge, retaining the height difference and clamping to its ends.
let to = la + dir * (ca - la).dot(dir).clamp(0.0, (lb - la).len());
let offset = to - ca;
let height = offset.dot(na);
let radial = (offset - na * height).len();
let min = (full_a && across && radial >= ra).then(|| (radial - ra).hypot(height));
Centre { distance: ca.dist(to), from: ca, to, min, max: None }
} else {
let (p, q) = line_line(ca, na, la, dir);
let d = p.dist(q);
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<Vec3>)]) -> Option<Slot> {
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 circles_at_different_heights_keep_both_actual_centres() {
let (a, b) = (Vec3::new(0.0, 0.0, 10.0), Vec3::new(34.0, 19.0, 32.0));
let c = centre(&circle(a, 4.0), &circle(b, 4.0)).unwrap();
assert_eq!((c.from, c.to), (a, b));
assert!((c.distance - 2001_f64.sqrt()).abs() < 1e-12);
assert!((c.min.unwrap() - (1517_f64.sqrt() - 8.0).hypot(22.0)).abs() < 1e-12);
assert!((c.max.unwrap() - (1517_f64.sqrt() + 8.0).hypot(22.0)).abs() < 1e-12);
let swapped = centre(&circle(b, 4.0), &circle(a, 4.0)).unwrap();
assert_eq!((swapped.from, swapped.to, swapped.distance), (b, a, c.distance));
}
#[test]
fn arcs_on_different_planes_measure_between_their_centres() {
let a = Shape::Round { center: Vec3::ZERO, axis: Vec3::Z, radius: 2.0, angle: PI, planar: true, lo: 0.0, hi: 0.0 };
let b = Shape::Round { center: Vec3::new(3.0, 4.0, 12.0), axis: Vec3::X, radius: 4.0, angle: PI / 2.0, planar: true, lo: 0.0, hi: 0.0 };
let c = centre(&a, &b).unwrap();
assert_eq!((c.from, c.to, c.distance), (Vec3::ZERO, Vec3::new(3.0, 4.0, 12.0), 13.0));
assert!(c.min.is_none() && c.max.is_none());
}
#[test]
fn a_circle_to_a_cylinder_uses_its_fixed_centre_and_the_cylinders_axis() {
let a = circle(Vec3::new(0.0, 0.0, 10.0), 4.0);
for axis in [Vec3::Z, Vec3::X] {
let b = Shape::Round { center: Vec3::new(34.0, 19.0, 27.0), axis, radius: 4.0, angle: TAU, planar: false, lo: -5.0, hi: 5.0 };
let c = centre(&a, &b).unwrap();
let expected = if axis == Vec3::Z { Vec3::new(34.0, 19.0, 10.0) } else { Vec3::new(0.0, 19.0, 27.0) };
assert_eq!((c.from, c.to), (Vec3::new(0.0, 0.0, 10.0), expected));
assert!((c.distance - c.from.dist(expected)).abs() < 1e-12);
let swapped = centre(&b, &a).unwrap();
assert_eq!((swapped.from, swapped.to, swapped.distance), (c.to, c.from, c.distance));
}
}
#[test]
fn two_cylindrical_faces_still_measure_axis_pitch() {
let a = Shape::Round { center: Vec3::new(0.0, 0.0, 5.0), axis: Vec3::Z, radius: 4.0, angle: TAU, planar: false, lo: -5.0, hi: 5.0 };
let b = Shape::Round { center: Vec3::new(34.0, 19.0, 27.0), axis: Vec3::Z, radius: 4.0, angle: TAU, planar: false, lo: -5.0, hi: 5.0 };
let c = centre(&a, &b).unwrap();
assert_eq!((c.from, c.to), (Vec3::new(0.0, 0.0, 5.0), Vec3::new(34.0, 19.0, 5.0)));
assert!((c.distance - 1517_f64.sqrt()).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 a_circle_or_arc_to_a_line_keeps_its_real_center() {
let edge = Shape::Line { a: Vec3::new(0.0, 0.0, 6.0), b: Vec3::new(68.0, 0.0, 6.0) };
for angle in [TAU, PI / 2.0] {
let round = Shape::Round { center: Vec3::new(34.0, 25.0, 20.0), axis: Vec3::Z, radius: 12.0, angle, planar: true, lo: 0.0, hi: 0.0 };
let c = centre(&round, &edge).unwrap();
assert_eq!((c.from, c.to), (Vec3::new(34.0, 25.0, 20.0), Vec3::new(34.0, 0.0, 6.0)));
assert!((c.distance - 25_f64.hypot(14.0)).abs() < 1e-12);
if angle == TAU {
assert!((c.min.unwrap() - 13_f64.hypot(14.0)).abs() < 1e-12);
} else {
assert!(c.min.is_none());
}
let swapped = centre(&edge, &round).unwrap();
assert_eq!((swapped.from, swapped.to, swapped.distance), (c.to, c.from, c.distance));
}
}
#[test]
fn a_tilted_arc_to_a_sloped_line_keeps_all_components() {
let edge = Shape::Line { a: Vec3::ZERO, b: Vec3::new(6.0, 0.0, 6.0) };
for axis in [Vec3::X, Vec3::Y, Vec3::Z] {
let arc = Shape::Round { center: Vec3::new(3.0, 4.0, 6.0), axis, radius: 2.0, angle: PI, planar: true, lo: 0.0, hi: 0.0 };
let c = centre(&arc, &edge).unwrap();
assert!(c.from.dist(Vec3::new(3.0, 4.0, 6.0)) < 1e-12);
assert!(c.to.dist(Vec3::new(4.5, 0.0, 4.5)) < 1e-12);
assert!((c.distance - 20.5_f64.sqrt()).abs() < 1e-12);
}
}
#[test]
fn a_circle_center_to_a_finite_edge_does_not_use_its_extension() {
for (center, expected) in [(Vec3::new(20.0, 3.0, 4.0), Vec3::new(10.0, 0.0, 0.0)), (Vec3::new(-10.0, 3.0, 4.0), Vec3::ZERO)] {
for (a, b) in [(Vec3::ZERO, Vec3::new(10.0, 0.0, 0.0)), (Vec3::new(10.0, 0.0, 0.0), Vec3::ZERO)] {
let c = centre(&circle(center, 2.0), &Shape::Line { a, b }).unwrap();
assert_eq!((c.from, c.to), (center, expected));
assert!((c.distance - 125_f64.sqrt()).abs() < 1e-12);
}
}
assert!(centre(&circle(Vec3::ZERO, 2.0), &Shape::Line { a: Vec3::ZERO, b: Vec3::ZERO }).is_none());
}
#[test]
fn coverage_of_a_half_and_a_whole_ring() {
let ring = |n: usize, span: f64| -> Vec<Vec3> {
(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());
}
}