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