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