Skip to main content

brep_kernel/props/mass_properties/
solid_props.rs

1use super::*;
2
3pub fn solid_mass_properties(solid: &BrepSolid) -> Result<MassProperties, String> {
4    let mut properties = MassProperties {
5        surface_area: 0.0,
6        volume: 0.0,
7    };
8    let mut volume_compensation = 0.0;
9    for shell in &solid.shells {
10        if shell.faces.is_empty() {
11            continue;
12        }
13        // Use the same local reference as the signed-volume orientation gate.
14        // World-origin flux terms can dwarf a small translated solid's volume;
15        // even tiny quadrature or boundary errors then dominate their sum.
16        let reference = shell_volume_reference(shell)?;
17        let kinds = [Integrand::Area, Integrand::VolumeAbout(reference)];
18        for face in &shell.faces {
19            let (area, volume) = if let Some(values) = biperiodic_band_integral(face, &kinds)? {
20                (values[0], values[1] / 3.0)
21            } else if !is_affine(&face.surface)? && !is_untrimmed(face)? {
22                // Preserve the shared cell decomposition and integration pass.
23                let values = integrate_trimmed_multi(face, &kinds)?;
24                (values[0], values[1] / 3.0)
25            } else {
26                (
27                    face_area(face)?,
28                    face_volume_contribution_about(face, reference)?,
29                )
30            };
31            properties.surface_area += area;
32            let next = properties.volume + volume;
33            if properties.volume.abs() >= volume.abs() {
34                volume_compensation += (properties.volume - next) + volume;
35            } else {
36                volume_compensation += (volume - next) + properties.volume;
37            }
38            properties.volume = next;
39        }
40    }
41    properties.volume += volume_compensation;
42    Ok(properties)
43}
44
45/// Exact signed volume only — same per-face integration paths as
46/// `solid_mass_properties` but without the surface-area pass. The boolean
47/// assembly orientation gate consumes only the volume sign, and the area
48/// integral costs as much again as the volume one.
49pub fn solid_signed_volume(solid: &BrepSolid) -> Result<f64, String> {
50    let mut volume = 0.0;
51    for shell in &solid.shells {
52        volume += shell_signed_volume(shell)?;
53    }
54    Ok(volume)
55}
56
57pub(super) fn shell_volume_reference(shell: &ShellRecord) -> Result<Vec3, String> {
58    let finite = |point: Vec3| point.x.is_finite() && point.y.is_finite() && point.z.is_finite();
59    for face in &shell.faces {
60        if let Some(coedge) = face
61            .loops
62            .first()
63            .and_then(|loop_record| loop_record.coedges.first())
64        {
65            if let Ok(domain) = coedge.pcurve.domain() {
66                if let Ok(uv) = coedge.pcurve.evaluate(domain[0]) {
67                    if let Ok(point) = face.surface.evaluate(uv.x, uv.y) {
68                        if finite(point) {
69                            return Ok(point);
70                        }
71                    }
72                }
73            }
74        }
75        if let Ok((u_breaks, v_breaks)) = surface_breaks(&face.surface) {
76            let u = 0.5 * (u_breaks[0] + u_breaks[u_breaks.len() - 1]);
77            let v = 0.5 * (v_breaks[0] + v_breaks[v_breaks.len() - 1]);
78            if let Ok(point) = face.surface.evaluate(u, v) {
79                if finite(point) {
80                    return Ok(point);
81                }
82            }
83        }
84        for row in &face.surface.control_points {
85            for control in row {
86                if let Ok(point) = control.point() {
87                    if finite(point) {
88                        return Ok(point);
89                    }
90                }
91            }
92        }
93    }
94    Err("mass_properties: shell has no finite geometric reference".to_string())
95}
96
97/// Exact signed volume of one closed shell. Multi-shell tessellation uses this
98/// to preserve the authored material orientation: exterior shells contribute
99/// positively, while a void boundary contributes negatively.
100pub(crate) fn shell_signed_volume(shell: &ShellRecord) -> Result<f64, String> {
101    let reference = shell_volume_reference(shell)?;
102
103    // A closed shell's divergence-theorem volume is independent of origin,
104    // but evaluating each face about the world origin can cancel enormous
105    // translated face terms down to a tiny cavity volume. Anchor the
106    // integrand on an authored boundary point and compensate the remaining
107    // face sum so the sign is stable for small, far-translated shells.
108    let mut volume = 0.0;
109    let mut compensation = 0.0;
110    for face in &shell.faces {
111        let contribution = face_volume_contribution_about(face, reference)?;
112        let next = volume + contribution;
113        if volume.abs() >= contribution.abs() {
114            compensation += (volume - next) + contribution;
115        } else {
116            compensation += (contribution - next) + volume;
117        }
118        volume = next;
119    }
120    Ok(volume + compensation)
121}
122
123/// Exact moments for affine faces via Green's theorem over the trim
124/// pcurves: ∬ g du dv = ∮ G dv with G(u,v) = ∫ g dt.  On an affine carrier
125/// every moment integrand is a low-degree polynomial, so the inner Gauss
126/// antiderivative is exact and the boundary quadrature has the same quality
127/// as `parameter_space_area` — no trim-polygon sampling error.  The loop
128/// winding supplies the orientation sign, matching the affine volume path.
129pub(super) fn affine_moment(face: &FaceRecord, kind: Integrand) -> Result<f64, String> {
130    let ku = crate::KnotVector::new(face.surface.knots_u.clone(), face.surface.degree_u)?;
131    let kv = crate::KnotVector::new(face.surface.knots_v.clone(), face.surface.degree_v)?;
132    let [u0, u1] = ku.domain();
133    let [v0, v1] = kv.domain();
134    let points = &face.surface.control_points;
135    let p00 = points[0][0].point()?;
136    let p10 = points[1][0].point()?;
137    let p01 = points[0][1].point()?;
138    let du = p10.sub(p00).scale(1.0 / (u1 - u0));
139    let dv = p01.sub(p00).scale(1.0 / (v1 - v0));
140    let weighted_normal = du.cross(dv);
141    let g = |u: f64, v: f64| {
142        let point = p00.add(du.scale(u - u0)).add(dv.scale(v - v0));
143        integrand_value(kind, point, weighted_normal)
144    };
145    let inner = |u: f64, v: f64| {
146        let half = (u - u0) * 0.5;
147        let middle = (u + u0) * 0.5;
148        let mut sum = 0.0;
149        for index in 0..GAUSS_X.len() {
150            sum += GAUSS_W[index] * g(middle + half * GAUSS_X[index], v);
151        }
152        sum * half
153    };
154    let mut total = 0.0;
155    for loop_record in &face.loops {
156        for coedge in &loop_record.coedges {
157            for pair in curve_breaks(&coedge.pcurve)?.windows(2) {
158                let half = (pair[1] - pair[0]) * 0.5;
159                let middle = (pair[1] + pair[0]) * 0.5;
160                for index in 0..GAUSS_X.len() {
161                    let parameter = middle + half * GAUSS_X[index];
162                    let (point, tangent) = coedge.pcurve.deriv1(parameter)?;
163                    total += GAUSS_W[index] * half * inner(point.x, point.y) * tangent.y;
164                }
165            }
166        }
167    }
168    Ok(total)
169}
170
171pub(super) fn face_moment(face: &FaceRecord, kind: Integrand) -> Result<f64, String> {
172    if is_affine(&face.surface)? {
173        return affine_moment(face, kind);
174    }
175    if let Some(values) = biperiodic_band_integral(face, &[kind])? {
176        return Ok(values[0]);
177    }
178    if is_untrimmed(face)? {
179        integrate_untrimmed(face, kind)
180    } else {
181        integrate_trimmed(face, kind)
182    }
183}
184
185/// Symmetric-matrix product `A[i][j] = M[k][i] * M[k][j]` reused by the Jacobi
186/// sweep — kept tiny and explicit rather than pulling in a matrix crate.
187pub(super) fn mat3_mul(a: [[f64; 3]; 3], b: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
188    let mut r = [[0.0f64; 3]; 3];
189    for i in 0..3 {
190        for j in 0..3 {
191            for k in 0..3 {
192                r[i][j] += a[i][k] * b[k][j];
193            }
194        }
195    }
196    r
197}
198
199pub(super) fn mat3_transpose(a: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
200    let mut r = [[0.0f64; 3]; 3];
201    for i in 0..3 {
202        for j in 0..3 {
203            r[i][j] = a[j][i];
204        }
205    }
206    r
207}
208
209/// Classic Jacobi eigenvalue iteration for a SYMMETRIC 3×3 matrix. Repeatedly
210/// applies a Givens rotation in the plane of the largest off-diagonal element,
211/// each rotation zeroing that element, until the matrix is diagonal to machine
212/// precision. Returns `(eigenvalues, vectors)` where `vectors` holds the
213/// eigenvectors as COLUMNS (`vectors[i][k]` is component `i` of eigenvector
214/// `k`), unsorted. For a symmetric matrix Jacobi is unconditionally
215/// convergent and the accumulated rotations stay orthonormal, so the columns
216/// are mutually orthonormal by construction.
217pub(super) fn jacobi_eigen_symmetric_3x3(matrix: [[f64; 3]; 3]) -> ([f64; 3], [[f64; 3]; 3]) {
218    // Symmetrize defensively against tiny asymmetry from round-off upstream.
219    let mut a = [
220        [matrix[0][0], 0.0, 0.0],
221        [0.0, matrix[1][1], 0.0],
222        [0.0, 0.0, matrix[2][2]],
223    ];
224    a[0][1] = 0.5 * (matrix[0][1] + matrix[1][0]);
225    a[1][0] = a[0][1];
226    a[0][2] = 0.5 * (matrix[0][2] + matrix[2][0]);
227    a[2][0] = a[0][2];
228    a[1][2] = 0.5 * (matrix[1][2] + matrix[2][1]);
229    a[2][1] = a[1][2];
230
231    let mut v = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
232    let scale = a[0][0].abs() + a[1][1].abs() + a[2][2].abs() + 1.0;
233    for _sweep in 0..64 {
234        // Pick the largest off-diagonal magnitude.
235        let pairs = [(0usize, 1usize), (0, 2), (1, 2)];
236        let (mut p, mut q, mut best) = (0usize, 1usize, 0.0f64);
237        for &(i, j) in &pairs {
238            if a[i][j].abs() > best {
239                best = a[i][j].abs();
240                p = i;
241                q = j;
242            }
243        }
244        if best <= 1e-18 * scale {
245            break;
246        }
247        // Angle that zeroes a[p][q]: t = tan(theta) is the smaller root of
248        // t² + 2·theta·t − 1 = 0 with theta = (a_qq − a_pp)/(2 a_pq).
249        let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
250        let t = if theta == 0.0 {
251            1.0
252        } else {
253            theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt())
254        };
255        let c = 1.0 / (t * t + 1.0).sqrt();
256        let s = t * c;
257        // Givens rotation J: J[p][p]=J[q][q]=c, J[p][q]=s, J[q][p]=−s.
258        let mut j = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
259        j[p][p] = c;
260        j[q][q] = c;
261        j[p][q] = s;
262        j[q][p] = -s;
263        // A ← Jᵀ A J   (drives a[p][q] to zero), V ← V J.
264        a = mat3_mul(mat3_transpose(j), mat3_mul(a, j));
265        v = mat3_mul(v, j);
266    }
267    ([a[0][0], a[1][1], a[2][2]], v)
268}
269
270/// Principal axes/moments from a centroidal inertia tensor (Golovanov §8.11).
271/// Diagonalizes the symmetric tensor, sorts the eigenpairs by moment
272/// ASCENDING, returns each eigenvector as a ROW (`axes[i]` pairs with
273/// `moments[i]`) normalized to unit length, and fixes the sign of the third
274/// axis so the frame is right-handed (determinant +1).
275pub(super) fn principal_frame(inertia: [[f64; 3]; 3]) -> ([f64; 3], [[f64; 3]; 3]) {
276    let (values, vectors) = jacobi_eigen_symmetric_3x3(inertia);
277    // Column k of `vectors` is the eigenvector for `values[k]`.
278    let mut order = [0usize, 1, 2];
279    order.sort_by(|&a, &b| values[a].total_cmp(&values[b]));
280    let mut moments = [0.0f64; 3];
281    let mut axes = [[0.0f64; 3]; 3];
282    for (slot, &k) in order.iter().enumerate() {
283        moments[slot] = values[k];
284        let mut axis = [vectors[0][k], vectors[1][k], vectors[2][k]];
285        let length = (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt();
286        if length > 0.0 {
287            axis = [axis[0] / length, axis[1] / length, axis[2] / length];
288        }
289        axes[slot] = axis;
290    }
291    // Right-hand the frame: if axis0 × axis1 points opposite axis2, flip axis2.
292    let cross = [
293        axes[0][1] * axes[1][2] - axes[0][2] * axes[1][1],
294        axes[0][2] * axes[1][0] - axes[0][0] * axes[1][2],
295        axes[0][0] * axes[1][1] - axes[0][1] * axes[1][0],
296    ];
297    let det = cross[0] * axes[2][0] + cross[1] * axes[2][1] + cross[2] * axes[2][2];
298    if det < 0.0 {
299        axes[2] = [-axes[2][0], -axes[2][1], -axes[2][2]];
300    }
301    (moments, axes)
302}
303
304/// Area, volume, centroid, and centroidal inertia (unit density).  Area and
305/// volume use the same exact paths as `solid_mass_properties`; the moment
306/// integrals use divergence-theorem surface quadrature (exact for untrimmed
307/// spans, trim-polygon scanline accuracy for trimmed faces).
308pub fn solid_mass_properties_full(solid: &BrepSolid) -> Result<FullMassProperties, String> {
309    let base = solid_mass_properties(solid)?;
310    const MOMENT_KINDS: [Integrand; 9] = [
311        Integrand::MomentX,
312        Integrand::MomentY,
313        Integrand::MomentZ,
314        Integrand::SecondXX,
315        Integrand::SecondYY,
316        Integrand::SecondZZ,
317        Integrand::ProductXY,
318        Integrand::ProductXZ,
319        Integrand::ProductYZ,
320    ];
321    let mut moments = [0.0f64; 3];
322    let mut seconds = [0.0f64; 3];
323    let mut products = [0.0f64; 3];
324    for shell in &solid.shells {
325        for face in &shell.faces {
326            let results = if !is_affine(&face.surface)? && !is_untrimmed(face)? {
327                // One cell decomposition, all nine moment integrands per
328                // station, instead of nine full passes per face.
329                integrate_trimmed_multi(face, &MOMENT_KINDS)?
330            } else {
331                MOMENT_KINDS
332                    .iter()
333                    .map(|kind| face_moment(face, *kind))
334                    .collect::<Result<Vec<_>, _>>()?
335            };
336            moments[0] += results[0];
337            moments[1] += results[1];
338            moments[2] += results[2];
339            seconds[0] += results[3];
340            seconds[1] += results[4];
341            seconds[2] += results[5];
342            products[0] += results[6];
343            products[1] += results[7];
344            products[2] += results[8];
345        }
346    }
347    let volume = base.volume;
348    if volume.abs() <= 1e-30 {
349        return Err("solid_mass_properties_full: non-positive volume".into());
350    }
351    let centroid = Vec3::new(
352        moments[0] / volume,
353        moments[1] / volume,
354        moments[2] / volume,
355    );
356    // Inertia about the origin, then parallel-axis down to the centroid.
357    let ixx =
358        seconds[1] + seconds[2] - volume * (centroid.y * centroid.y + centroid.z * centroid.z);
359    let iyy =
360        seconds[0] + seconds[2] - volume * (centroid.x * centroid.x + centroid.z * centroid.z);
361    let izz =
362        seconds[0] + seconds[1] - volume * (centroid.x * centroid.x + centroid.y * centroid.y);
363    let ixy = -(products[0] - volume * centroid.x * centroid.y);
364    let ixz = -(products[1] - volume * centroid.x * centroid.z);
365    let iyz = -(products[2] - volume * centroid.y * centroid.z);
366    let inertia = [[ixx, ixy, ixz], [ixy, iyy, iyz], [ixz, iyz, izz]];
367    let (principal_moments, principal_axes) = principal_frame(inertia);
368    Ok(FullMassProperties {
369        surface_area: base.surface_area,
370        volume,
371        centroid,
372        inertia,
373        principal_moments,
374        principal_axes,
375    })
376}