Skip to main content

brep_kernel/props/mass_properties/
solid_props.rs

1use super::*;
2use super::mass_profile;
3use web_time::Instant;
4
5/// The static facts a `mass.face` line carries beside the route: the
6/// recognized carrier kind, the patch degrees and closed directions, and the
7/// trim's size. Read only when `BREP_PROFILE_MASS_FACES` armed the lines.
8fn face_line_meta(face: &FaceRecord) -> (&'static str, usize, usize, bool, bool, usize, usize) {
9    // `kind_label` spells the general carrier with spaces; the profile line is
10    // whitespace-tokenized, so collapse it to one word.
11    let kind = match face.surface.analytic().map(|analytic| analytic.kind_label()) {
12        Some("Surface of revolution") => "Revolution",
13        Some(label) => label,
14        None => "Fitted",
15    };
16    let (closed_u, closed_v) = face.surface.closed_directions().unwrap_or((false, false));
17    let coedges = face
18        .loops
19        .iter()
20        .map(|loop_record| loop_record.coedges.len())
21        .sum();
22    (
23        kind,
24        face.surface.degree_u,
25        face.surface.degree_v,
26        closed_u,
27        closed_v,
28        face.loops.len(),
29        coedges,
30    )
31}
32
33/// Emit one `mass.face` line for the face just integrated. `before` is the
34/// counter snapshot taken ahead of its work and `started` the wall clock at
35/// the same point; both are `None` unless the lines are armed.
36fn emit_face_line(
37    call: &str,
38    shell: usize,
39    face_index: usize,
40    face: &FaceRecord,
41    before: Option<super::profile::MassProfile>,
42    started: Option<Instant>,
43) {
44    let (Some(before), Some(started)) = (before, started) else {
45        return;
46    };
47    let wall_ms = started.elapsed().as_secs_f64() * 1_000.0;
48    let (kind, degree_u, degree_v, closed_u, closed_v, loops, coedges) = face_line_meta(face);
49    super::profile::emit_face(
50        call, shell, face_index, kind, degree_u, degree_v, closed_u, closed_v, loops, coedges,
51        before, wall_ms,
52    );
53}
54
55pub fn solid_mass_properties(solid: &BrepSolid) -> Result<MassProperties, String> {
56    let token = super::profile::begin("solid_mass_properties");
57    let result = solid_mass_properties_inner(solid);
58    super::profile::end(token);
59    if let (Some(dump), Ok(properties)) = (super::profile::identity_dump(), result.as_ref()) {
60        super::profile::emit_identity(
61            dump,
62            "solid_mass_properties",
63            solid,
64            &[
65                ("volume_bits", properties.volume),
66                ("area_bits", properties.surface_area),
67            ],
68        );
69    }
70    result
71}
72
73fn solid_mass_properties_inner(solid: &BrepSolid) -> Result<MassProperties, String> {
74    let mut properties = MassProperties {
75        surface_area: 0.0,
76        volume: 0.0,
77    };
78    let mut volume_compensation = 0.0;
79    let face_lines = super::profile::face_lines_enabled();
80    for (shell_index, shell) in solid.shells.iter().enumerate() {
81        if shell.faces.is_empty() {
82            continue;
83        }
84        // Use the same local reference as the signed-volume orientation gate.
85        // World-origin flux terms can dwarf a small translated solid's volume;
86        // even tiny quadrature or boundary errors then dominate their sum.
87        let reference = shell_volume_reference(shell)?;
88        let kinds = [Integrand::Area, Integrand::VolumeAbout(reference)];
89        for (face_index, face) in shell.faces.iter().enumerate() {
90            mass_profile(|p| p.faces += 1);
91            let line_before = face_lines.then(super::profile::snapshot);
92            let line_started = face_lines.then(Instant::now);
93            let gate_started = super::profile::profile_started();
94            let (area, volume) = if let Some(values) = biperiodic_band_integral(face, &kinds)? {
95                mass_profile(|p| {
96                    p.route_biperiodic += 1;
97                    p.gate_ms += super::profile::elapsed_ms(gate_started);
98                });
99                (values[0], values[1] / 3.0)
100            } else if !is_affine(&face.surface)? && !is_untrimmed(face)? {
101                mass_profile(|p| {
102                    p.trimmed_faces += 1;
103                    p.gate_ms += super::profile::elapsed_ms(gate_started);
104                });
105                // Preserve the shared cell decomposition and integration pass.
106                let values = integrate_trimmed_multi(face, &kinds)?;
107                (values[0], values[1] / 3.0)
108            } else if is_affine(&face.surface)? {
109                mass_profile(|p| {
110                    p.route_affine += 1;
111                    p.gate_ms += super::profile::elapsed_ms(gate_started);
112                });
113                // One parameter-space area and one planar metric for both
114                // numbers, instead of `face_area` and
115                // `face_volume_contribution_about` each walking the trim.
116                affine_area_and_volume_about(face, reference)?
117            } else {
118                mass_profile(|p| {
119                    p.route_untrimmed += 1;
120                    p.gate_ms += super::profile::elapsed_ms(gate_started);
121                });
122                // One station sweep carrying both integrands, instead of one
123                // sweep per integrand.
124                let started = super::profile::profile_started();
125                let values = integrate_untrimmed_multi(face, &kinds)?;
126                mass_profile(|p| p.untrimmed_ms += super::profile::elapsed_ms(started));
127                (values[0], values[1] / 3.0)
128            };
129            emit_face_line(
130                "solid_mass_properties",
131                shell_index,
132                face_index,
133                face,
134                line_before,
135                line_started,
136            );
137            properties.surface_area += area;
138            let next = properties.volume + volume;
139            if properties.volume.abs() >= volume.abs() {
140                volume_compensation += (properties.volume - next) + volume;
141            } else {
142                volume_compensation += (volume - next) + properties.volume;
143            }
144            properties.volume = next;
145        }
146    }
147    properties.volume += volume_compensation;
148    Ok(properties)
149}
150
151/// Exact signed volume only — same per-face integration paths as
152/// `solid_mass_properties` but without the surface-area pass. The boolean
153/// assembly orientation gate consumes only the volume sign, and the area
154/// integral costs as much again as the volume one.
155pub fn solid_signed_volume(solid: &BrepSolid) -> Result<f64, String> {
156    let token = super::profile::begin("solid_signed_volume");
157    let mut volume = 0.0;
158    for shell in &solid.shells {
159        volume += match shell_signed_volume(shell) {
160            Ok(value) => value,
161            Err(error) => {
162                super::profile::end(token);
163                return Err(error);
164            }
165        };
166    }
167    super::profile::end(token);
168    if let Some(dump) = super::profile::identity_dump() {
169        super::profile::emit_identity(dump, "solid_signed_volume", solid, &[("signed_bits", volume)]);
170    }
171    Ok(volume)
172}
173
174pub(super) fn shell_volume_reference(shell: &ShellRecord) -> Result<Vec3, String> {
175    let finite = |point: Vec3| point.x.is_finite() && point.y.is_finite() && point.z.is_finite();
176    for face in &shell.faces {
177        if let Some(coedge) = face
178            .loops
179            .first()
180            .and_then(|loop_record| loop_record.coedges.first())
181        {
182            if let Ok(domain) = coedge.pcurve.domain() {
183                if let Ok(uv) = coedge.pcurve.evaluate(domain[0]) {
184                    if let Ok(point) = face.surface.evaluate(uv.x, uv.y) {
185                        if finite(point) {
186                            return Ok(point);
187                        }
188                    }
189                }
190            }
191        }
192        if let Ok((u_breaks, v_breaks)) = surface_breaks(&face.surface) {
193            let u = 0.5 * (u_breaks[0] + u_breaks[u_breaks.len() - 1]);
194            let v = 0.5 * (v_breaks[0] + v_breaks[v_breaks.len() - 1]);
195            if let Ok(point) = face.surface.evaluate(u, v) {
196                if finite(point) {
197                    return Ok(point);
198                }
199            }
200        }
201        for row in &face.surface.control_points {
202            for control in row {
203                if let Ok(point) = control.point() {
204                    if finite(point) {
205                        return Ok(point);
206                    }
207                }
208            }
209        }
210    }
211    Err("mass_properties: shell has no finite geometric reference".to_string())
212}
213
214/// Exact signed volume of one closed shell. Multi-shell tessellation uses this
215/// to preserve the authored material orientation: exterior shells contribute
216/// positively, while a void boundary contributes negatively.
217pub(crate) fn shell_signed_volume(shell: &ShellRecord) -> Result<f64, String> {
218    let reference = shell_volume_reference(shell)?;
219
220    // A closed shell's divergence-theorem volume is independent of origin,
221    // but evaluating each face about the world origin can cancel enormous
222    // translated face terms down to a tiny cavity volume. Anchor the
223    // integrand on an authored boundary point and compensate the remaining
224    // face sum so the sign is stable for small, far-translated shells.
225    let mut volume = 0.0;
226    let mut compensation = 0.0;
227    let face_lines = super::profile::face_lines_enabled();
228    for (face_index, face) in shell.faces.iter().enumerate() {
229        mass_profile(|p| p.faces += 1);
230        let line_before = face_lines.then(super::profile::snapshot);
231        let line_started = face_lines.then(Instant::now);
232        let contribution = face_volume_contribution_about(face, reference)?;
233        emit_face_line(
234            "shell_signed_volume",
235            0,
236            face_index,
237            face,
238            line_before,
239            line_started,
240        );
241        let next = volume + contribution;
242        if volume.abs() >= contribution.abs() {
243            compensation += (volume - next) + contribution;
244        } else {
245            compensation += (contribution - next) + volume;
246        }
247        volume = next;
248    }
249    Ok(volume + compensation)
250}
251
252/// Exact moments for affine faces via Green's theorem over the trim
253/// pcurves: ∬ g du dv = ∮ G dv with G(u,v) = ∫ g dt.  On an affine carrier
254/// every moment integrand is a low-degree polynomial, so the inner Gauss
255/// antiderivative is exact and the boundary quadrature has the same quality
256/// as `parameter_space_area` — no trim-polygon sampling error.  The loop
257/// winding supplies the orientation sign, matching the affine volume path.
258pub(super) fn affine_moment(face: &FaceRecord, kind: Integrand) -> Result<f64, String> {
259    let ku = crate::KnotVector::new(face.surface.knots_u.clone(), face.surface.degree_u)?;
260    let kv = crate::KnotVector::new(face.surface.knots_v.clone(), face.surface.degree_v)?;
261    let [u0, u1] = ku.domain();
262    let [v0, v1] = kv.domain();
263    let points = &face.surface.control_points;
264    let p00 = points[0][0].point()?;
265    let p10 = points[1][0].point()?;
266    let p01 = points[0][1].point()?;
267    let du = p10.sub(p00).scale(1.0 / (u1 - u0));
268    let dv = p01.sub(p00).scale(1.0 / (v1 - v0));
269    let weighted_normal = du.cross(dv);
270    let g = |u: f64, v: f64| {
271        let point = p00.add(du.scale(u - u0)).add(dv.scale(v - v0));
272        integrand_value(kind, point, weighted_normal)
273    };
274    let inner = |u: f64, v: f64| {
275        let half = (u - u0) * 0.5;
276        let middle = (u + u0) * 0.5;
277        let mut sum = 0.0;
278        for index in 0..GAUSS_X.len() {
279            sum += GAUSS_W[index] * g(middle + half * GAUSS_X[index], v);
280        }
281        sum * half
282    };
283    let mut total = 0.0;
284    for loop_record in &face.loops {
285        for coedge in &loop_record.coedges {
286            for pair in curve_breaks(&coedge.pcurve)?.windows(2) {
287                // The inner antiderivative is a polynomial in u on an affine
288                // carrier and is exact at any order; the boundary walk is the
289                // pcurve's own, so it takes the pcurve's panels (`rule`).
290                for panel in rule::curve_panels(&coedge.pcurve, pair[0], pair[1])? {
291                    for (parameter, weight) in panel.stations() {
292                        let (point, tangent) = coedge.pcurve.deriv1(parameter)?;
293                        total += weight * inner(point.x, point.y) * tangent.y;
294                    }
295                }
296            }
297        }
298    }
299    // The loops closed as the area closes them (`closed_parameter_space_area`):
300    // an open boundary integral moves with the uv origin. Along a straight
301    // chord the integrand is a polynomial of degree at most four, which the
302    // eight-station rule integrates exactly.
303    for (end, start) in loop_closing_chords(face)? {
304        let step = start.y - end.y;
305        if step == 0.0 {
306            continue;
307        }
308        for index in 0..GAUSS_X.len() {
309            let fraction = 0.5 * (GAUSS_X[index] + 1.0);
310            let u = end.x + (start.x - end.x) * fraction;
311            let v = end.y + step * fraction;
312            total += 0.5 * GAUSS_W[index] * inner(u, v) * step;
313        }
314    }
315    Ok(total)
316}
317
318pub(super) fn face_moment(face: &FaceRecord, kind: Integrand) -> Result<f64, String> {
319    if is_affine(&face.surface)? {
320        return affine_moment(face, kind);
321    }
322    if let Some(values) = biperiodic_band_integral(face, &[kind])? {
323        return Ok(values[0]);
324    }
325    if is_untrimmed(face)? {
326        integrate_untrimmed(face, kind)
327    } else {
328        integrate_trimmed(face, kind)
329    }
330}
331
332/// Symmetric-matrix product `A[i][j] = M[k][i] * M[k][j]` reused by the Jacobi
333/// sweep — kept tiny and explicit rather than pulling in a matrix crate.
334pub(super) fn mat3_mul(a: [[f64; 3]; 3], b: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
335    let mut r = [[0.0f64; 3]; 3];
336    for i in 0..3 {
337        for j in 0..3 {
338            for k in 0..3 {
339                r[i][j] += a[i][k] * b[k][j];
340            }
341        }
342    }
343    r
344}
345
346pub(super) fn mat3_transpose(a: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
347    let mut r = [[0.0f64; 3]; 3];
348    for i in 0..3 {
349        for j in 0..3 {
350            r[i][j] = a[j][i];
351        }
352    }
353    r
354}
355
356/// Classic Jacobi eigenvalue iteration for a SYMMETRIC 3×3 matrix. Repeatedly
357/// applies a Givens rotation in the plane of the largest off-diagonal element,
358/// each rotation zeroing that element, until the matrix is diagonal to machine
359/// precision. Returns `(eigenvalues, vectors)` where `vectors` holds the
360/// eigenvectors as COLUMNS (`vectors[i][k]` is component `i` of eigenvector
361/// `k`), unsorted. For a symmetric matrix Jacobi is unconditionally
362/// convergent and the accumulated rotations stay orthonormal, so the columns
363/// are mutually orthonormal by construction.
364pub(super) fn jacobi_eigen_symmetric_3x3(matrix: [[f64; 3]; 3]) -> ([f64; 3], [[f64; 3]; 3]) {
365    // Symmetrize defensively against tiny asymmetry from round-off upstream.
366    let mut a = [
367        [matrix[0][0], 0.0, 0.0],
368        [0.0, matrix[1][1], 0.0],
369        [0.0, 0.0, matrix[2][2]],
370    ];
371    a[0][1] = 0.5 * (matrix[0][1] + matrix[1][0]);
372    a[1][0] = a[0][1];
373    a[0][2] = 0.5 * (matrix[0][2] + matrix[2][0]);
374    a[2][0] = a[0][2];
375    a[1][2] = 0.5 * (matrix[1][2] + matrix[2][1]);
376    a[2][1] = a[1][2];
377
378    let mut v = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
379    let scale = a[0][0].abs() + a[1][1].abs() + a[2][2].abs() + 1.0;
380    for _sweep in 0..64 {
381        // Pick the largest off-diagonal magnitude.
382        let pairs = [(0usize, 1usize), (0, 2), (1, 2)];
383        let (mut p, mut q, mut best) = (0usize, 1usize, 0.0f64);
384        for &(i, j) in &pairs {
385            if a[i][j].abs() > best {
386                best = a[i][j].abs();
387                p = i;
388                q = j;
389            }
390        }
391        if best <= 1e-18 * scale {
392            break;
393        }
394        // Angle that zeroes a[p][q]: t = tan(theta) is the smaller root of
395        // t² + 2·theta·t − 1 = 0 with theta = (a_qq − a_pp)/(2 a_pq).
396        let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
397        let t = if theta == 0.0 {
398            1.0
399        } else {
400            theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt())
401        };
402        let c = 1.0 / (t * t + 1.0).sqrt();
403        let s = t * c;
404        // Givens rotation J: J[p][p]=J[q][q]=c, J[p][q]=s, J[q][p]=−s.
405        let mut j = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
406        j[p][p] = c;
407        j[q][q] = c;
408        j[p][q] = s;
409        j[q][p] = -s;
410        // A ← Jᵀ A J   (drives a[p][q] to zero), V ← V J.
411        a = mat3_mul(mat3_transpose(j), mat3_mul(a, j));
412        v = mat3_mul(v, j);
413    }
414    ([a[0][0], a[1][1], a[2][2]], v)
415}
416
417/// Principal axes/moments from a centroidal inertia tensor (Golovanov §8.11).
418/// Diagonalizes the symmetric tensor, sorts the eigenpairs by moment
419/// ASCENDING, returns each eigenvector as a ROW (`axes[i]` pairs with
420/// `moments[i]`) normalized to unit length, and fixes the sign of the third
421/// axis so the frame is right-handed (determinant +1).
422pub(super) fn principal_frame(inertia: [[f64; 3]; 3]) -> ([f64; 3], [[f64; 3]; 3]) {
423    let (values, vectors) = jacobi_eigen_symmetric_3x3(inertia);
424    // Column k of `vectors` is the eigenvector for `values[k]`.
425    let mut order = [0usize, 1, 2];
426    order.sort_by(|&a, &b| values[a].total_cmp(&values[b]));
427    let mut moments = [0.0f64; 3];
428    let mut axes = [[0.0f64; 3]; 3];
429    for (slot, &k) in order.iter().enumerate() {
430        moments[slot] = values[k];
431        let mut axis = [vectors[0][k], vectors[1][k], vectors[2][k]];
432        let length = (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt();
433        if length > 0.0 {
434            axis = [axis[0] / length, axis[1] / length, axis[2] / length];
435        }
436        axes[slot] = axis;
437    }
438    // Right-hand the frame: if axis0 × axis1 points opposite axis2, flip axis2.
439    let cross = [
440        axes[0][1] * axes[1][2] - axes[0][2] * axes[1][1],
441        axes[0][2] * axes[1][0] - axes[0][0] * axes[1][2],
442        axes[0][0] * axes[1][1] - axes[0][1] * axes[1][0],
443    ];
444    let det = cross[0] * axes[2][0] + cross[1] * axes[2][1] + cross[2] * axes[2][2];
445    if det < 0.0 {
446        axes[2] = [-axes[2][0], -axes[2][1], -axes[2][2]];
447    }
448    (moments, axes)
449}
450
451/// Area, volume, centroid, and centroidal inertia (unit density).  Area and
452/// volume use the same exact paths as `solid_mass_properties`; the moment
453/// integrals use divergence-theorem surface quadrature (exact for untrimmed
454/// spans, trim-polygon scanline accuracy for trimmed faces).
455pub fn solid_mass_properties_full(solid: &BrepSolid) -> Result<FullMassProperties, String> {
456    let base = solid_mass_properties(solid)?;
457    let token = super::profile::begin("solid_mass_properties_full.moments");
458    let result = solid_mass_properties_full_moments(solid, base);
459    super::profile::end(token);
460    result
461}
462
463fn solid_mass_properties_full_moments(
464    solid: &BrepSolid,
465    base: MassProperties,
466) -> Result<FullMassProperties, String> {
467    const MOMENT_KINDS: [Integrand; 9] = [
468        Integrand::MomentX,
469        Integrand::MomentY,
470        Integrand::MomentZ,
471        Integrand::SecondXX,
472        Integrand::SecondYY,
473        Integrand::SecondZZ,
474        Integrand::ProductXY,
475        Integrand::ProductXZ,
476        Integrand::ProductYZ,
477    ];
478    let mut moments = [0.0f64; 3];
479    let mut seconds = [0.0f64; 3];
480    let mut products = [0.0f64; 3];
481    for shell in &solid.shells {
482        for face in &shell.faces {
483            mass_profile(|p| p.faces += 1);
484            let results = if !is_affine(&face.surface)? && !is_untrimmed(face)? {
485                mass_profile(|p| p.trimmed_faces += 1);
486                // One cell decomposition, all nine moment integrands per
487                // station, instead of nine full passes per face.
488                integrate_trimmed_multi(face, &MOMENT_KINDS)?
489            } else {
490                MOMENT_KINDS
491                    .iter()
492                    .map(|kind| face_moment(face, *kind))
493                    .collect::<Result<Vec<_>, _>>()?
494            };
495            moments[0] += results[0];
496            moments[1] += results[1];
497            moments[2] += results[2];
498            seconds[0] += results[3];
499            seconds[1] += results[4];
500            seconds[2] += results[5];
501            products[0] += results[6];
502            products[1] += results[7];
503            products[2] += results[8];
504        }
505    }
506    let volume = base.volume;
507    if volume.abs() <= 1e-30 {
508        return Err("solid_mass_properties_full: non-positive volume".into());
509    }
510    let centroid = Vec3::new(
511        moments[0] / volume,
512        moments[1] / volume,
513        moments[2] / volume,
514    );
515    // Inertia about the origin, then parallel-axis down to the centroid.
516    let ixx =
517        seconds[1] + seconds[2] - volume * (centroid.y * centroid.y + centroid.z * centroid.z);
518    let iyy =
519        seconds[0] + seconds[2] - volume * (centroid.x * centroid.x + centroid.z * centroid.z);
520    let izz =
521        seconds[0] + seconds[1] - volume * (centroid.x * centroid.x + centroid.y * centroid.y);
522    let ixy = -(products[0] - volume * centroid.x * centroid.y);
523    let ixz = -(products[1] - volume * centroid.x * centroid.z);
524    let iyz = -(products[2] - volume * centroid.y * centroid.z);
525    let inertia = [[ixx, ixy, ixz], [ixy, iyy, iyz], [ixz, iyz, izz]];
526    let (principal_moments, principal_axes) = principal_frame(inertia);
527    Ok(FullMassProperties {
528        surface_area: base.surface_area,
529        volume,
530        centroid,
531        inertia,
532        principal_moments,
533        principal_axes,
534    })
535}