Skip to main content

brepkit_check/properties/
mod.rs

1//! Geometric properties: volume, area, center of mass, inertia tensor.
2
3pub mod accumulator;
4pub mod analytic;
5pub mod bbox;
6pub mod face_integrator;
7
8pub use accumulator::GProps;
9
10use brepkit_math::aabb::Aabb3;
11use brepkit_math::vec::{Point3, Vec3};
12use brepkit_topology::Topology;
13use brepkit_topology::face::FaceId;
14use brepkit_topology::solid::SolidId;
15
16use crate::CheckError;
17
18/// Options for property computation.
19#[derive(Debug, Clone)]
20pub struct PropertiesOptions {
21    /// Gauss quadrature order (default 5).
22    pub gauss_order: usize,
23    /// Adaptive integration tolerance (default 1e-6).
24    pub adaptive_eps: f64,
25    /// Maximum adaptive subdivision depth (default 8).
26    pub max_depth: usize,
27}
28
29impl Default for PropertiesOptions {
30    fn default() -> Self {
31        Self {
32            gauss_order: 5,
33            adaptive_eps: 1e-6,
34            max_depth: 8,
35        }
36    }
37}
38
39/// Compute the bounding box of a solid.
40///
41/// # Errors
42///
43/// Returns an error if any topology entity is missing or the solid has no vertices.
44pub fn bounding_box(topo: &Topology, solid: SolidId) -> Result<Aabb3, CheckError> {
45    bbox::bounding_box(topo, solid)
46}
47
48/// Compute the volume of a solid via face integration.
49///
50/// Uses the divergence theorem: V = (1/3) sum of integral P dot N dA
51/// over every face of the solid, cavity shells included.
52///
53/// # Errors
54///
55/// Returns an error if any topology entity is missing or integration fails.
56pub fn solid_volume(
57    topo: &Topology,
58    solid: SolidId,
59    options: &PropertiesOptions,
60) -> Result<f64, CheckError> {
61    let mut total_volume = 0.0;
62    for fid in brepkit_topology::explorer::solid_faces(topo, solid)? {
63        let contrib = face_integrator::integrate_face(topo, fid, options.gauss_order)?;
64        total_volume += contrib.volume;
65    }
66    Ok(total_volume)
67}
68
69/// Compute the total surface area of a solid.
70///
71/// Sums the area of every face of the solid, cavity shells included.
72///
73/// # Errors
74///
75/// Returns an error if any topology entity is missing or integration fails.
76pub fn solid_area(
77    topo: &Topology,
78    solid: SolidId,
79    options: &PropertiesOptions,
80) -> Result<f64, CheckError> {
81    let mut total_area = 0.0;
82    for fid in brepkit_topology::explorer::solid_faces(topo, solid)? {
83        let contrib = face_integrator::integrate_face(topo, fid, options.gauss_order)?;
84        total_area += contrib.area;
85    }
86    Ok(total_area)
87}
88
89/// Compute the center of mass of a solid.
90///
91/// Uses the divergence theorem: for each coordinate axis, integrates
92/// `(1/2) x_i^2 * n_i` over the solid's boundary, then divides by total
93/// volume to obtain the volumetric centroid (solid CoM).
94///
95/// # Errors
96///
97/// Returns an error if any topology entity is missing, integration fails,
98/// or the solid has zero volume.
99pub fn center_of_mass(
100    topo: &Topology,
101    solid: SolidId,
102    options: &PropertiesOptions,
103) -> Result<Point3, CheckError> {
104    let mut total_volume = 0.0;
105    let mut mx = 0.0;
106    let mut my = 0.0;
107    let mut mz = 0.0;
108
109    for fid in brepkit_topology::explorer::solid_faces(topo, solid)? {
110        let contrib = face_integrator::integrate_face(topo, fid, options.gauss_order)?;
111        total_volume += contrib.volume;
112        mx += contrib.volume_moment_x;
113        my += contrib.volume_moment_y;
114        mz += contrib.volume_moment_z;
115    }
116
117    if total_volume.abs() < 1e-30 {
118        return Err(CheckError::IntegrationFailed(
119            "solid has zero volume".into(),
120        ));
121    }
122
123    Ok(Point3::new(
124        mx / total_volume,
125        my / total_volume,
126        mz / total_volume,
127    ))
128}
129
130/// Compute the v-range for an analytic surface by projecting face wire
131/// vertices onto the given axis.
132///
133/// Iterates over all wires (outer + inner) of `face_id`, projects each vertex
134/// position onto `axis` relative to `origin`, and returns `(v_min, v_max)`.
135/// If the face has no distinguishable range (e.g. a single vertex),
136/// returns `(-1.0, 1.0)` as a fallback.
137///
138/// # Errors
139///
140/// Returns an error if any topology entity is missing.
141pub fn axial_v_range(
142    topo: &Topology,
143    face_id: FaceId,
144    origin: Point3,
145    axis: Vec3,
146) -> Result<(f64, f64), CheckError> {
147    let face_data = topo.face(face_id)?;
148    let outer = topo.wire(face_data.outer_wire())?;
149
150    let mut v_min = f64::MAX;
151    let mut v_max = f64::MIN;
152
153    // Chain outer wire and inner wires.
154    let inner_wires: Vec<_> = face_data
155        .inner_wires()
156        .iter()
157        .filter_map(|&wid| topo.wire(wid).ok())
158        .collect();
159
160    for wire in std::iter::once(outer).chain(inner_wires.iter().copied()) {
161        for oe in wire.edges() {
162            let edge = topo.edge(oe.edge())?;
163            for vid in [oe.oriented_start(edge), oe.oriented_end(edge)] {
164                let pt = topo.vertex(vid)?.point();
165                let to_pt = Vec3::new(
166                    pt.x() - origin.x(),
167                    pt.y() - origin.y(),
168                    pt.z() - origin.z(),
169                );
170                let v = axis.dot(to_pt);
171                v_min = v_min.min(v);
172                v_max = v_max.max(v);
173            }
174        }
175    }
176
177    if v_min < v_max {
178        Ok((v_min, v_max))
179    } else {
180        Ok((-1.0, 1.0))
181    }
182}
183
184#[cfg(test)]
185#[allow(clippy::unwrap_used, clippy::expect_used)]
186mod tests {
187    use super::*;
188    use brepkit_math::vec::Point3;
189    use brepkit_topology::Topology;
190    use brepkit_topology::test_utils::make_unit_cube_manifold;
191
192    #[test]
193    fn gprops_accumulator_two_cubes() {
194        // Two unit cubes side by side along x-axis
195        let a = analytic::box_props(1.0, 1.0, 1.0);
196        let mut b = analytic::box_props(1.0, 1.0, 1.0);
197        // Shift b's center to (1.5, 0.5, 0.5) — as if placed at x=1
198        b.center = Point3::new(1.5, 0.5, 0.5);
199
200        let mut combined = a;
201        combined.add(&b);
202
203        // Total volume = 2
204        assert!((combined.mass - 2.0).abs() < 1e-12);
205        // Combined center = (1.0, 0.5, 0.5)
206        assert!((combined.center.x() - 1.0).abs() < 1e-12);
207        assert!((combined.center.y() - 0.5).abs() < 1e-12);
208        assert!((combined.center.z() - 0.5).abs() < 1e-12);
209    }
210
211    #[test]
212    fn box_props_volume_and_com() {
213        let props = analytic::box_props(2.0, 3.0, 4.0);
214        assert!((props.mass - 24.0).abs() < 1e-12);
215        assert!((props.center.x() - 1.0).abs() < 1e-12);
216        assert!((props.center.y() - 1.5).abs() < 1e-12);
217        assert!((props.center.z() - 2.0).abs() < 1e-12);
218        // Ixx = 24/12 * (9 + 16) = 50
219        assert!((props.inertia[0] - 50.0).abs() < 1e-12);
220    }
221
222    #[test]
223    fn sphere_props_volume() {
224        let props = analytic::sphere_props(1.0);
225        let expected = 4.0 / 3.0 * std::f64::consts::PI;
226        assert!((props.mass - expected).abs() < 1e-12);
227        assert!((props.center.x()).abs() < 1e-12);
228        assert!((props.center.y()).abs() < 1e-12);
229        assert!((props.center.z()).abs() < 1e-12);
230    }
231
232    #[test]
233    fn cylinder_props_volume_and_com() {
234        let props = analytic::cylinder_props(1.0, 2.0);
235        let expected_v = std::f64::consts::PI * 2.0;
236        assert!((props.mass - expected_v).abs() < 1e-12);
237        assert!((props.center.z() - 1.0).abs() < 1e-12);
238    }
239
240    #[test]
241    fn cone_full_volume() {
242        let props = analytic::cone_props(1.0, 0.0, 3.0);
243        let expected_v = std::f64::consts::PI * 3.0 / 3.0; // pi * h/3 * r^2
244        assert!((props.mass - expected_v).abs() < 1e-12);
245        // CoM of full cone at h/4 from base
246        assert!((props.center.z() - 0.75).abs() < 1e-12);
247    }
248
249    #[test]
250    fn torus_props_volume() {
251        let props = analytic::torus_props(3.0, 1.0);
252        let expected_v = 2.0 * std::f64::consts::PI * std::f64::consts::PI * 3.0;
253        assert!((props.mass - expected_v).abs() < 1e-12);
254    }
255
256    #[test]
257    fn box_surface_area() {
258        let area = analytic::box_area(2.0, 3.0, 4.0);
259        // 2*(6 + 12 + 8) = 52
260        assert!((area - 52.0).abs() < 1e-12);
261    }
262
263    #[test]
264    fn sphere_surface_area() {
265        let area = analytic::sphere_area(2.0);
266        let expected = 4.0 * std::f64::consts::PI * 4.0;
267        assert!((area - expected).abs() < 1e-12);
268    }
269
270    #[test]
271    fn inertia_matrix_symmetric() {
272        let mut props = GProps::new();
273        props.inertia = [10.0, 20.0, 30.0, 1.0, 2.0, 3.0];
274        let mat = props.matrix_of_inertia();
275        // Off-diagonal symmetry
276        assert!((mat[0][1] - mat[1][0]).abs() < 1e-15);
277        assert!((mat[0][2] - mat[2][0]).abs() < 1e-15);
278        assert!((mat[1][2] - mat[2][1]).abs() < 1e-15);
279        // Diagonal values
280        assert!((mat[0][0] - 10.0).abs() < 1e-15);
281        assert!((mat[1][1] - 20.0).abs() < 1e-15);
282        assert!((mat[2][2] - 30.0).abs() < 1e-15);
283    }
284
285    #[test]
286    fn bounding_box_unit_cube() {
287        let mut topo = Topology::new();
288        let solid = make_unit_cube_manifold(&mut topo);
289        let aabb = bounding_box(&topo, solid).unwrap();
290        // Unit cube at origin: min=(0,0,0), max=(1,1,1)
291        assert!((aabb.min.x()).abs() < 1e-12);
292        assert!((aabb.min.y()).abs() < 1e-12);
293        assert!((aabb.min.z()).abs() < 1e-12);
294        assert!((aabb.max.x() - 1.0).abs() < 1e-12);
295        assert!((aabb.max.y() - 1.0).abs() < 1e-12);
296        assert!((aabb.max.z() - 1.0).abs() < 1e-12);
297    }
298
299    #[test]
300    fn gauss_volume_matches_analytic() {
301        let mut topo = Topology::new();
302        let solid = make_unit_cube_manifold(&mut topo);
303        let options = PropertiesOptions::default();
304        let vol = solid_volume(&topo, solid, &options).unwrap();
305        // Unit cube volume = 1.0
306        assert!((vol - 1.0).abs() < 1e-10, "expected volume 1.0, got {vol}");
307    }
308
309    #[test]
310    fn gauss_area_matches_analytic() {
311        let mut topo = Topology::new();
312        let solid = make_unit_cube_manifold(&mut topo);
313        let options = PropertiesOptions::default();
314        let area = solid_area(&topo, solid, &options).unwrap();
315        // Unit cube surface area = 6.0
316        assert!((area - 6.0).abs() < 1e-10, "expected area 6.0, got {area}");
317    }
318
319    #[test]
320    fn gauss_com_matches_analytic() {
321        let mut topo = Topology::new();
322        let solid = make_unit_cube_manifold(&mut topo);
323        let options = PropertiesOptions::default();
324        let com = center_of_mass(&topo, solid, &options).unwrap();
325        // Unit cube CoM at (0.5, 0.5, 0.5)
326        assert!((com.x() - 0.5).abs() < 1e-10, "com.x = {}", com.x());
327        assert!((com.y() - 0.5).abs() < 1e-10, "com.y = {}", com.y());
328        assert!((com.z() - 0.5).abs() < 1e-10, "com.z = {}", com.z());
329    }
330
331    #[test]
332    fn accumulator_default_is_zero() {
333        let props = GProps::default();
334        assert!((props.mass).abs() < 1e-15);
335        assert!((props.center.x()).abs() < 1e-15);
336        assert!((props.center.y()).abs() < 1e-15);
337        assert!((props.center.z()).abs() < 1e-15);
338        for &c in &props.inertia {
339            assert!(c.abs() < 1e-15);
340        }
341    }
342}