Skip to main content

brep_kernel/props/
carrier_dimensions.rs

1//! The DIMENSIONS of a face's carrier surface and an edge's curve, for the
2//! Properties Info panel: a cylinder's radius and diameter, a cone's
3//! half-angle, a sphere's radius, a torus's two radii, a circular edge's
4//! radius and diameter.
5//!
6//! Read-only reporting. Nothing here changes geometry or feeds a modelling
7//! decision; the labels below may therefore name a partial revolution of a
8//! straight generatrix a "Cylinder" (what a user calls a half-bore), where
9//! `AnalyticSurface::kind_label` keeps calling it a "Surface of revolution".
10
11use crate::{AnalyticSurface, NurbsCurve, NurbsSurface, RevolutionFrame, Vec3};
12
13/// Samples taken along a generatrix or an edge to classify it.
14const SAMPLES: usize = 17;
15
16/// Relative band within which samples count as on one line or one circle.
17const RELATIVE_TOLERANCE: f64 = 1e-7;
18
19/// A carrier's classification and its named dimensions in display order
20/// (millimetres, or degrees for an angle key ending in `Deg`).
21#[derive(Clone, Debug, PartialEq)]
22pub struct CarrierDimensions {
23    /// `"Cylinder"`, `"Cone"`, `"Sphere"`, `"Torus"`, `"Circle"` or `"Arc"`.
24    pub label: &'static str,
25    /// `(key, value)` pairs: `radius`, `diameter`, `halfAngleDeg`,
26    /// `majorRadius`, `minorRadius`, `sweepDeg`.
27    pub values: Vec<(&'static str, f64)>,
28}
29
30fn round_dimensions(radius: f64) -> Vec<(&'static str, f64)> {
31    vec![("radius", radius), ("diameter", 2.0 * radius)]
32}
33
34/// The dimensions of a surface's recognised carrier, or `None` for a plane,
35/// a free-form patch, or a revolution whose generatrix is neither straight
36/// nor circular.
37pub fn surface_carrier_dimensions(surface: &NurbsSurface) -> Option<CarrierDimensions> {
38    match surface.analytic()? {
39        AnalyticSurface::Plane { .. } => None,
40        AnalyticSurface::RuledRevolution { rho0, rho1, height, .. } => {
41            Some(ruled_dimensions(*rho0, *rho1, *height))
42        }
43        AnalyticSurface::Sphere { radius, .. } => Some(CarrierDimensions {
44            label: "Sphere",
45            values: round_dimensions(*radius),
46        }),
47        AnalyticSurface::Torus { major_radius, minor_radius, .. } => Some(torus(*major_radius, *minor_radius)),
48        AnalyticSurface::Revolution { frame, generatrix, .. } => revolution_dimensions(frame, generatrix),
49    }
50}
51
52fn torus(major_radius: f64, minor_radius: f64) -> CarrierDimensions {
53    CarrierDimensions {
54        label: "Torus",
55        values: vec![("majorRadius", major_radius), ("minorRadius", minor_radius)],
56    }
57}
58
59/// A straight generatrix from radius `rho0` to `rho1` over axial `height`:
60/// a cylinder when the radii agree, else a cone with its half-angle.
61fn ruled_dimensions(rho0: f64, rho1: f64, height: f64) -> CarrierDimensions {
62    let scale = rho0.abs().max(rho1.abs()).max(height.abs()).max(1.0);
63    if (rho0 - rho1).abs() <= RELATIVE_TOLERANCE * scale {
64        return CarrierDimensions {
65            label: "Cylinder",
66            values: round_dimensions(0.5 * (rho0 + rho1).abs()),
67        };
68    }
69    CarrierDimensions {
70        label: "Cone",
71        values: vec![("halfAngleDeg", (rho1 - rho0).abs().atan2(height.abs()).to_degrees())],
72    }
73}
74
75/// Classify a general revolution by its generatrix in the (radial, axial)
76/// half-plane: straight → cylinder or cone, circular → sphere (centre on the
77/// axis) or torus.
78fn revolution_dimensions(frame: &RevolutionFrame, generatrix: &NurbsCurve) -> Option<CarrierDimensions> {
79    let [t0, t1] = generatrix.domain().ok()?;
80    let mut points = Vec::with_capacity(SAMPLES);
81    for index in 0..SAMPLES {
82        let t = t0 + (t1 - t0) * index as f64 / (SAMPLES - 1) as f64;
83        let d = generatrix.evaluate(t).ok()?.sub(frame.origin);
84        let axial = d.dot(frame.axis);
85        let radial = d.sub(frame.axis.scale(axial)).length();
86        points.push(Vec3::new(radial, axial, 0.0));
87    }
88    let (first, last) = (points[0], points[SAMPLES - 1]);
89    let scale = points.iter().map(|p| p.x.abs().max(p.y.abs())).fold(1.0, f64::max);
90    let tolerance = RELATIVE_TOLERANCE * scale;
91    if on_line(&points, tolerance) {
92        return Some(ruled_dimensions(first.x, last.x, last.y - first.y));
93    }
94    let (centre, radius) = circle_through(&points, tolerance)?;
95    if centre.x.abs() <= tolerance {
96        return Some(CarrierDimensions { label: "Sphere", values: round_dimensions(radius) });
97    }
98    Some(torus(centre.x, radius))
99}
100
101/// The dimensions of a curve over `[t0, t1]` when it is a circle or an arc
102/// of one (radius, diameter, and an arc's sweep), else `None`.
103pub fn curve_carrier_dimensions(curve: &NurbsCurve, t0: f64, t1: f64) -> Option<CarrierDimensions> {
104    let (lo, hi) = (t0.min(t1), t0.max(t1));
105    if !(hi > lo) {
106        return None;
107    }
108    let mut points = Vec::with_capacity(SAMPLES);
109    for index in 0..SAMPLES {
110        points.push(curve.evaluate(lo + (hi - lo) * index as f64 / (SAMPLES - 1) as f64).ok()?);
111    }
112    let scale = points
113        .iter()
114        .map(|p| p.x.abs().max(p.y.abs()).max(p.z.abs()))
115        .fold(1.0, f64::max);
116    let tolerance = RELATIVE_TOLERANCE * scale;
117    if on_line(&points, tolerance) {
118        return None;
119    }
120    let (centre, radius) = circle_through(&points, tolerance)?;
121    let mut values = round_dimensions(radius);
122    let closed = points[0].sub(points[SAMPLES - 1]).length() <= tolerance;
123    if closed {
124        return Some(CarrierDimensions { label: "Circle", values });
125    }
126    // Sweep as the sum of the sampled steps' angles, so an arc past a half
127    // turn reads as such.
128    let sweep: f64 = points
129        .windows(2)
130        .map(|pair| {
131            let a = pair[0].sub(centre);
132            let b = pair[1].sub(centre);
133            a.cross(b).length().atan2(a.dot(b))
134        })
135        .sum();
136    values.push(("sweepDeg", sweep.to_degrees()));
137    Some(CarrierDimensions { label: "Arc", values })
138}
139
140/// Whether every point lies within `tolerance` of the line through the first
141/// and last (or, for a closed run, the first and the farthest).
142fn on_line(points: &[Vec3], tolerance: f64) -> bool {
143    let first = points[0];
144    let Some(far) = points
145        .iter()
146        .copied()
147        .max_by(|a, b| a.sub(first).length().total_cmp(&b.sub(first).length()))
148    else {
149        return true;
150    };
151    let Ok(direction) = far.sub(first).normalized() else {
152        return true;
153    };
154    points.iter().all(|p| {
155        let d = p.sub(first);
156        d.sub(direction.scale(d.dot(direction))).length() <= tolerance
157    })
158}
159
160/// The circle through three well-spread samples, accepted only when every
161/// sample lies on it (radius and plane) within `tolerance`.
162fn circle_through(points: &[Vec3], tolerance: f64) -> Option<(Vec3, f64)> {
163    let n = points.len();
164    let closed = points[0].sub(points[n - 1]).length() <= tolerance;
165    // A closed run's start and end coincide, so take three thirds instead.
166    let (a, b, c) = if closed {
167        (points[0], points[n / 3], points[2 * n / 3])
168    } else {
169        (points[0], points[n / 2], points[n - 1])
170    };
171    let ab = b.sub(a);
172    let ac = c.sub(a);
173    let normal = ab.cross(ac);
174    let centre = crate::circumcenter(a, b, c)?;
175    let radius = a.sub(centre).length();
176    let unit_normal = normal.normalized().ok()?;
177    let on_circle = points.iter().all(|p| {
178        let d = p.sub(centre);
179        (d.length() - radius).abs() <= tolerance && d.dot(unit_normal).abs() <= tolerance
180    });
181    on_circle.then_some((centre, radius))
182}
183