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_cached(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
73/// Validate the memo against the current geometry on EVERY read: topology
74/// records are publicly editable, and copies may have been transformed or healed.
75fn solid_mass_properties_cached(solid: &BrepSolid) -> Result<MassProperties, String> {
76    let identity = face_set_identity(solid);
77    if let Some((cached_identity, properties)) = solid.mass_properties_cache.entry.get() {
78        if cached_identity == identity {
79            if super::profile::mass_profile_enabled() {
80                let measured = solid_mass_properties_inner(solid)?;
81                assert_eq!(properties.volume.to_bits(), measured.volume.to_bits(),
82                    "mass cache changed volume");
83                assert_eq!(properties.surface_area.to_bits(), measured.surface_area.to_bits(),
84                    "mass cache changed area");
85                eprintln!("mass.cache hit faces={} verified=true", identity.faces);
86            }
87            return Ok(properties);
88        }
89    }
90    // Never retain a failed computation, nor an old entry for modified geometry.
91    solid.mass_properties_cache.entry.set(None);
92    let properties = solid_mass_properties_inner(solid)?;
93    solid.mass_properties_cache.entry.set(Some((identity, properties)));
94    Ok(properties)
95}
96
97fn solid_mass_properties_inner(solid: &BrepSolid) -> Result<MassProperties, String> {
98    let mut properties = MassProperties {
99        surface_area: 0.0,
100        volume: 0.0,
101    };
102    let mut volume_compensation = 0.0;
103    let face_lines = super::profile::face_lines_enabled();
104    for (shell_index, shell) in solid.shells.iter().enumerate() {
105        if shell.faces.is_empty() {
106            continue;
107        }
108        // Use the same local reference as the signed-volume orientation gate.
109        // World-origin flux terms can dwarf a small translated solid's volume;
110        // even tiny quadrature or boundary errors then dominate their sum.
111        let reference = shell_volume_reference(shell)?;
112        let kinds = [Integrand::Area, Integrand::VolumeAbout(reference)];
113        for (face_index, face) in shell.faces.iter().enumerate() {
114            mass_profile(|p| p.faces += 1);
115            let line_before = face_lines.then(super::profile::snapshot);
116            let line_started = face_lines.then(Instant::now);
117            let gate_started = super::profile::profile_started();
118            let (area, volume) = if let Some(values) = biperiodic_band_integral(face, &kinds)? {
119                mass_profile(|p| {
120                    p.route_biperiodic += 1;
121                    p.gate_ms += super::profile::elapsed_ms(gate_started);
122                });
123                (values[0], values[1] / 3.0)
124            } else if !is_affine(&face.surface)? && !is_untrimmed(face)? {
125                mass_profile(|p| {
126                    p.trimmed_faces += 1;
127                    p.gate_ms += super::profile::elapsed_ms(gate_started);
128                });
129                // Preserve the shared cell decomposition and integration pass.
130                let values = integrate_trimmed_multi(face, &kinds)?;
131                (values[0], values[1] / 3.0)
132            } else if is_affine(&face.surface)? {
133                mass_profile(|p| {
134                    p.route_affine += 1;
135                    p.gate_ms += super::profile::elapsed_ms(gate_started);
136                });
137                // One parameter-space area and one planar metric for both
138                // numbers, instead of `face_area` and
139                // `face_volume_contribution_about` each walking the trim.
140                affine_area_and_volume_about(face, reference)?
141            } else {
142                mass_profile(|p| {
143                    p.route_untrimmed += 1;
144                    p.gate_ms += super::profile::elapsed_ms(gate_started);
145                });
146                // One station sweep carrying both integrands, instead of one
147                // sweep per integrand.
148                let started = super::profile::profile_started();
149                let values = integrate_untrimmed_multi(face, &kinds)?;
150                mass_profile(|p| p.untrimmed_ms += super::profile::elapsed_ms(started));
151                (values[0], values[1] / 3.0)
152            };
153            emit_face_line(
154                "solid_mass_properties",
155                shell_index,
156                face_index,
157                face,
158                line_before,
159                line_started,
160            );
161            properties.surface_area += area;
162            let next = properties.volume + volume;
163            if properties.volume.abs() >= volume.abs() {
164                volume_compensation += (properties.volume - next) + volume;
165            } else {
166                volume_compensation += (volume - next) + properties.volume;
167            }
168            properties.volume = next;
169        }
170    }
171    properties.volume += volume_compensation;
172    Ok(properties)
173}
174
175/// Exact signed volume. A single nonempty shell shares its area/volume
176/// measurement with subsequent property queries. Multiple shells retain the
177/// original per-shell compensated sum: substituting the across-face sum can
178/// change the last bits and an orientation gate must not change its answer.
179pub fn solid_signed_volume(solid: &BrepSolid) -> Result<f64, String> {
180    let token = super::profile::begin("solid_signed_volume");
181    let result = if solid.shells.len() == 1 && !solid.shells[0].faces.is_empty() {
182        match solid_mass_properties_cached(solid) {
183            Ok(properties) => {
184                if super::profile::mass_profile_enabled() {
185                    let original = solid_signed_volume_uncached(solid)
186                        .expect("shared mass measurement changed a signed-volume refusal");
187                    assert_eq!(properties.volume.to_bits(), original.to_bits(),
188                        "shared mass measurement changed signed volume");
189                }
190                Ok(properties.volume)
191            }
192            // Area computation must not introduce a new signed-volume refusal.
193            Err(_) => solid_signed_volume_uncached(solid),
194        }
195    } else {
196        solid_signed_volume_uncached(solid)
197    };
198    super::profile::end(token);
199    if let (Some(dump), Ok(volume)) = (super::profile::identity_dump(), result.as_ref()) {
200        super::profile::emit_identity(dump, "solid_signed_volume", solid, &[("signed_bits", *volume)]);
201    }
202    result
203}
204
205/// The margin, as a fraction of the sum of the face contributions' magnitudes,
206/// inside which [`solid_signed_volume_for_sign`] trusts no reading of its own
207/// and integrates exactly. The skipped corrections are zero to rounding
208/// (collinear control points, 1e-12 of the extent), so this is a very wide
209/// safety margin, not a measured error.
210const SIGN_READING_MARGIN: f64 = 1e-6;
211
212/// [`solid_signed_volume`] read for its SIGN against `bar`, as the boolean's
213/// assembly reads it (`volume <= tolerance³` refuses an inverted shell).
214///
215/// The exact integration spends most of a trimmed wall's time on the sliver
216/// correction behind every chord of its rim pcurves; where such a pcurve is a
217/// straight line in uv (a bore's rims, 85 % of the plate case's 105 ms check:
218/// 256 chords per wall half, 442 368 correction stations in all) the sliver
219/// has no area and the correction is zero to rounding, so this reading skips
220/// it (`trimmed::with_straight_corrections_skipped`). Every other chord, face
221/// and route reads exactly as `solid_signed_volume` does; nothing is cached.
222///
223/// Native two-sphere supports with the certified trim scope can return a
224/// positive lower bound; `BREP_SPHERE_SIGN_BOUND=0|off` keeps the old path. Only a
225/// comparison against `bar` is meaningful for that result; it is never stored
226/// in the full mass cache. If the bound cannot prove `> bar`, the old path runs.
227///
228/// The verdict is the exact check's: when the reading sits within
229/// [`SIGN_READING_MARGIN`] of the magnitudes of the bar, the exact value is
230/// returned instead, bit for bit. Callers compare the result with `bar`.
231pub fn solid_signed_volume_for_sign(solid: &BrepSolid, bar: f64) -> Result<f64, String> {
232    let enabled = match std::env::var("BREP_SPHERE_SIGN_BOUND") {
233        Ok(value) => value == "1" || value == "on",
234        Err(std::env::VarError::NotPresent) => true,
235        Err(_) => false,
236    };
237    solid_signed_volume_for_sign_with_bound(solid, bar, enabled)
238}
239
240pub(super) fn solid_signed_volume_for_sign_with_bound(solid: &BrepSolid, bar: f64, enabled: bool) -> Result<f64, String> {
241    if enabled {
242        if let Some(lower) = super::sphere_sign::positive_volume_bound(solid) {
243            if bar.is_finite() && lower > bar {
244                if std::env::var("BREP_PROFILE").is_ok() {
245                    eprintln!("mass.sign_certificate kind=trimmed_spheres lower={lower:.9} bar={bar:.3e}");
246                }
247                return Ok(lower);
248            }
249        }
250    }
251    let token = super::profile::begin("solid_signed_volume_for_sign");
252    let outcome = super::trimmed::with_straight_corrections_skipped(|| {
253        let (mut reading, mut magnitude) = (0.0, 0.0);
254        for shell in &solid.shells {
255            let (volume, faces) = shell_volume_sign_reading(shell)?;
256            reading += volume;
257            magnitude += faces;
258        }
259        Ok::<(f64, f64), String>((reading, magnitude))
260    });
261    super::profile::end(token);
262    let (reading, magnitude) = outcome?;
263    if (reading - bar).abs() > SIGN_READING_MARGIN * magnitude {
264        return Ok(reading);
265    }
266    solid_signed_volume(solid)
267}
268
269/// [`shell_signed_volume`]'s sum under the caller's correction policy, with
270/// the sum of the face contributions' magnitudes beside it (the scale the
271/// sign reading's margin is read against).
272fn shell_volume_sign_reading(shell: &ShellRecord) -> Result<(f64, f64), String> {
273    let reference = shell_volume_reference(shell)?;
274    if std::env::var_os("BREP_PROFILE").is_some() {
275        eprintln!(
276            "mass.sign_reference faces={} reference=({:.9},{:.9},{:.9})",
277            shell.faces.len(), reference.x, reference.y, reference.z
278        );
279    }
280    let mut volume = 0.0;
281    let mut compensation = 0.0;
282    let mut magnitude = 0.0;
283    let face_lines = super::profile::face_lines_enabled();
284    for (face_index, face) in shell.faces.iter().enumerate() {
285        mass_profile(|p| p.faces += 1);
286        let line_before = face_lines.then(super::profile::snapshot);
287        let line_started = face_lines.then(Instant::now);
288        // A pure function of the face's content and the reference, served
289        // from the reading memo when this face was read about this reference
290        // before (`reading_memo.rs`). The stored term is the one read under
291        // `with_straight_corrections_skipped` — this function is that
292        // policy's only caller, which is what makes a key without the policy
293        // sound; a reader of the exact path must not share this memo. A
294        // failed reading is stored as `None` and recomputed below, so its
295        // error still propagates.
296        let contribution = match crate::reading_memo::memoized(
297            crate::reading_memo::Reading::SignContribution,
298            super::identity::face_contribution_identity(face, reference),
299            || face_volume_contribution_about(face, reference).ok(),
300        ) {
301            Some(contribution) => contribution,
302            None => face_volume_contribution_about(face, reference)?,
303        };
304        emit_face_line("shell_volume_sign_reading", 0, face_index, face, line_before, line_started);
305        magnitude += contribution.abs();
306        let next = volume + contribution;
307        if volume.abs() >= contribution.abs() {
308            compensation += (volume - next) + contribution;
309        } else {
310            compensation += (contribution - next) + volume;
311        }
312        volume = next;
313    }
314    Ok((volume + compensation, magnitude))
315}
316
317fn solid_signed_volume_uncached(solid: &BrepSolid) -> Result<f64, String> {
318    let mut volume = 0.0;
319    for shell in &solid.shells {
320        volume += shell_signed_volume(shell)?;
321    }
322    Ok(volume)
323}
324
325pub(super) fn shell_volume_reference(shell: &ShellRecord) -> Result<Vec3, String> {
326    let finite = |point: Vec3| point.x.is_finite() && point.y.is_finite() && point.z.is_finite();
327    for face in &shell.faces {
328        if let Some(coedge) = face
329            .loops
330            .first()
331            .and_then(|loop_record| loop_record.coedges.first())
332        {
333            if let Ok(domain) = coedge.pcurve.domain() {
334                if let Ok(uv) = coedge.pcurve.evaluate(domain[0]) {
335                    if let Ok(point) = face.surface.evaluate(uv.x, uv.y) {
336                        if finite(point) {
337                            return Ok(point);
338                        }
339                    }
340                }
341            }
342        }
343        if let Ok((u_breaks, v_breaks)) = surface_breaks(&face.surface) {
344            let u = 0.5 * (u_breaks[0] + u_breaks[u_breaks.len() - 1]);
345            let v = 0.5 * (v_breaks[0] + v_breaks[v_breaks.len() - 1]);
346            if let Ok(point) = face.surface.evaluate(u, v) {
347                if finite(point) {
348                    return Ok(point);
349                }
350            }
351        }
352        for row in &face.surface.control_points {
353            for control in row {
354                if let Ok(point) = control.point() {
355                    if finite(point) {
356                        return Ok(point);
357                    }
358                }
359            }
360        }
361    }
362    Err("mass_properties: shell has no finite geometric reference".to_string())
363}
364
365/// Exact signed volume of one closed shell. Multi-shell tessellation uses this
366/// to preserve the authored material orientation: exterior shells contribute
367/// positively, while a void boundary contributes negatively.
368pub(crate) fn shell_signed_volume(shell: &ShellRecord) -> Result<f64, String> {
369    let reference = shell_volume_reference(shell)?;
370
371    // A closed shell's divergence-theorem volume is independent of origin,
372    // but evaluating each face about the world origin can cancel enormous
373    // translated face terms down to a tiny cavity volume. Anchor the
374    // integrand on an authored boundary point and compensate the remaining
375    // face sum so the sign is stable for small, far-translated shells.
376    let mut volume = 0.0;
377    let mut compensation = 0.0;
378    let face_lines = super::profile::face_lines_enabled();
379    for (face_index, face) in shell.faces.iter().enumerate() {
380        mass_profile(|p| p.faces += 1);
381        let line_before = face_lines.then(super::profile::snapshot);
382        let line_started = face_lines.then(Instant::now);
383        let contribution = face_volume_contribution_about(face, reference)?;
384        emit_face_line(
385            "shell_signed_volume",
386            0,
387            face_index,
388            face,
389            line_before,
390            line_started,
391        );
392        let next = volume + contribution;
393        if volume.abs() >= contribution.abs() {
394            compensation += (volume - next) + contribution;
395        } else {
396            compensation += (contribution - next) + volume;
397        }
398        volume = next;
399    }
400    Ok(volume + compensation)
401}
402
403/// Exact moments for affine faces via Green's theorem over the trim
404/// pcurves: ∬ g du dv = ∮ G dv with G(u,v) = ∫ g dt.  On an affine carrier
405/// every moment integrand is a low-degree polynomial, so the inner Gauss
406/// antiderivative is exact and the boundary quadrature has the same quality
407/// as `parameter_space_area` — no trim-polygon sampling error.  The loop
408/// winding supplies the orientation sign, matching the affine volume path.
409pub(super) fn affine_moment(face: &FaceRecord, kind: Integrand) -> Result<f64, String> {
410    let ku = crate::KnotVector::new(face.surface.knots_u.clone(), face.surface.degree_u)?;
411    let kv = crate::KnotVector::new(face.surface.knots_v.clone(), face.surface.degree_v)?;
412    let [u0, u1] = ku.domain();
413    let [v0, v1] = kv.domain();
414    let points = &face.surface.control_points;
415    let p00 = points[0][0].point()?;
416    let p10 = points[1][0].point()?;
417    let p01 = points[0][1].point()?;
418    let du = p10.sub(p00).scale(1.0 / (u1 - u0));
419    let dv = p01.sub(p00).scale(1.0 / (v1 - v0));
420    let weighted_normal = du.cross(dv);
421    let g = |u: f64, v: f64| {
422        let point = p00.add(du.scale(u - u0)).add(dv.scale(v - v0));
423        integrand_value(kind, point, weighted_normal)
424    };
425    let inner = |u: f64, v: f64| {
426        let half = (u - u0) * 0.5;
427        let middle = (u + u0) * 0.5;
428        let mut sum = 0.0;
429        for index in 0..GAUSS_X.len() {
430            sum += GAUSS_W[index] * g(middle + half * GAUSS_X[index], v);
431        }
432        sum * half
433    };
434    let mut total = 0.0;
435    for loop_record in &face.loops {
436        for coedge in &loop_record.coedges {
437            for pair in curve_breaks(&coedge.pcurve)?.windows(2) {
438                // The inner antiderivative is a polynomial in u on an affine
439                // carrier and is exact at any order; the boundary walk is the
440                // pcurve's own, so it takes the pcurve's panels (`rule`).
441                for panel in rule::curve_panels(&coedge.pcurve, pair[0], pair[1])? {
442                    for (parameter, weight) in panel.stations() {
443                        let (point, tangent) = coedge.pcurve.deriv1(parameter)?;
444                        total += weight * inner(point.x, point.y) * tangent.y;
445                    }
446                }
447            }
448        }
449    }
450    // The loops closed as the area closes them (`closed_parameter_space_area`):
451    // an open boundary integral moves with the uv origin. Along a straight
452    // chord the integrand is a polynomial of degree at most four, which the
453    // eight-station rule integrates exactly.
454    for (end, start) in loop_closing_chords(face)? {
455        let step = start.y - end.y;
456        if step == 0.0 {
457            continue;
458        }
459        for index in 0..GAUSS_X.len() {
460            let fraction = 0.5 * (GAUSS_X[index] + 1.0);
461            let u = end.x + (start.x - end.x) * fraction;
462            let v = end.y + step * fraction;
463            total += 0.5 * GAUSS_W[index] * inner(u, v) * step;
464        }
465    }
466    Ok(total)
467}
468
469pub(super) fn face_moment(face: &FaceRecord, kind: Integrand) -> Result<f64, String> {
470    if is_affine(&face.surface)? {
471        return affine_moment(face, kind);
472    }
473    if let Some(values) = biperiodic_band_integral(face, &[kind])? {
474        return Ok(values[0]);
475    }
476    if is_untrimmed(face)? {
477        integrate_untrimmed(face, kind)
478    } else {
479        integrate_trimmed(face, kind)
480    }
481}
482
483/// Symmetric-matrix product `A[i][j] = M[k][i] * M[k][j]` reused by the Jacobi
484/// sweep — kept tiny and explicit rather than pulling in a matrix crate.
485pub(super) fn mat3_mul(a: [[f64; 3]; 3], b: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
486    let mut r = [[0.0f64; 3]; 3];
487    for i in 0..3 {
488        for j in 0..3 {
489            for k in 0..3 {
490                r[i][j] += a[i][k] * b[k][j];
491            }
492        }
493    }
494    r
495}
496
497pub(super) fn mat3_transpose(a: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
498    let mut r = [[0.0f64; 3]; 3];
499    for i in 0..3 {
500        for j in 0..3 {
501            r[i][j] = a[j][i];
502        }
503    }
504    r
505}
506
507/// Classic Jacobi eigenvalue iteration for a SYMMETRIC 3×3 matrix. Repeatedly
508/// applies a Givens rotation in the plane of the largest off-diagonal element,
509/// each rotation zeroing that element, until the matrix is diagonal to machine
510/// precision. Returns `(eigenvalues, vectors)` where `vectors` holds the
511/// eigenvectors as COLUMNS (`vectors[i][k]` is component `i` of eigenvector
512/// `k`), unsorted. For a symmetric matrix Jacobi is unconditionally
513/// convergent and the accumulated rotations stay orthonormal, so the columns
514/// are mutually orthonormal by construction.
515pub(super) fn jacobi_eigen_symmetric_3x3(matrix: [[f64; 3]; 3]) -> ([f64; 3], [[f64; 3]; 3]) {
516    // Symmetrize defensively against tiny asymmetry from round-off upstream.
517    let mut a = [
518        [matrix[0][0], 0.0, 0.0],
519        [0.0, matrix[1][1], 0.0],
520        [0.0, 0.0, matrix[2][2]],
521    ];
522    a[0][1] = 0.5 * (matrix[0][1] + matrix[1][0]);
523    a[1][0] = a[0][1];
524    a[0][2] = 0.5 * (matrix[0][2] + matrix[2][0]);
525    a[2][0] = a[0][2];
526    a[1][2] = 0.5 * (matrix[1][2] + matrix[2][1]);
527    a[2][1] = a[1][2];
528
529    let mut v = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
530    let scale = a[0][0].abs() + a[1][1].abs() + a[2][2].abs() + 1.0;
531    for _sweep in 0..64 {
532        // Pick the largest off-diagonal magnitude.
533        let pairs = [(0usize, 1usize), (0, 2), (1, 2)];
534        let (mut p, mut q, mut best) = (0usize, 1usize, 0.0f64);
535        for &(i, j) in &pairs {
536            if a[i][j].abs() > best {
537                best = a[i][j].abs();
538                p = i;
539                q = j;
540            }
541        }
542        if best <= 1e-18 * scale {
543            break;
544        }
545        // Angle that zeroes a[p][q]: t = tan(theta) is the smaller root of
546        // t² + 2·theta·t − 1 = 0 with theta = (a_qq − a_pp)/(2 a_pq).
547        let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
548        let t = if theta == 0.0 {
549            1.0
550        } else {
551            theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt())
552        };
553        let c = 1.0 / (t * t + 1.0).sqrt();
554        let s = t * c;
555        // Givens rotation J: J[p][p]=J[q][q]=c, J[p][q]=s, J[q][p]=−s.
556        let mut j = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
557        j[p][p] = c;
558        j[q][q] = c;
559        j[p][q] = s;
560        j[q][p] = -s;
561        // A ← Jᵀ A J   (drives a[p][q] to zero), V ← V J.
562        a = mat3_mul(mat3_transpose(j), mat3_mul(a, j));
563        v = mat3_mul(v, j);
564    }
565    ([a[0][0], a[1][1], a[2][2]], v)
566}
567
568/// Principal axes/moments from a centroidal inertia tensor (Golovanov §8.11).
569/// Diagonalizes the symmetric tensor, sorts the eigenpairs by moment
570/// ASCENDING, returns each eigenvector as a ROW (`axes[i]` pairs with
571/// `moments[i]`) normalized to unit length, and fixes the sign of the third
572/// axis so the frame is right-handed (determinant +1).
573pub(super) fn principal_frame(inertia: [[f64; 3]; 3]) -> ([f64; 3], [[f64; 3]; 3]) {
574    let (values, vectors) = jacobi_eigen_symmetric_3x3(inertia);
575    // Column k of `vectors` is the eigenvector for `values[k]`.
576    let mut order = [0usize, 1, 2];
577    order.sort_by(|&a, &b| values[a].total_cmp(&values[b]));
578    let mut moments = [0.0f64; 3];
579    let mut axes = [[0.0f64; 3]; 3];
580    for (slot, &k) in order.iter().enumerate() {
581        moments[slot] = values[k];
582        let mut axis = [vectors[0][k], vectors[1][k], vectors[2][k]];
583        let length = (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt();
584        if length > 0.0 {
585            axis = [axis[0] / length, axis[1] / length, axis[2] / length];
586        }
587        axes[slot] = axis;
588    }
589    // Right-hand the frame: if axis0 × axis1 points opposite axis2, flip axis2.
590    let cross = [
591        axes[0][1] * axes[1][2] - axes[0][2] * axes[1][1],
592        axes[0][2] * axes[1][0] - axes[0][0] * axes[1][2],
593        axes[0][0] * axes[1][1] - axes[0][1] * axes[1][0],
594    ];
595    let det = cross[0] * axes[2][0] + cross[1] * axes[2][1] + cross[2] * axes[2][2];
596    if det < 0.0 {
597        axes[2] = [-axes[2][0], -axes[2][1], -axes[2][2]];
598    }
599    (moments, axes)
600}
601
602/// Area, volume, centroid, and centroidal inertia (unit density).  Area and
603/// volume use the same exact paths as `solid_mass_properties`; the moment
604/// integrals use divergence-theorem surface quadrature (exact for untrimmed
605/// spans, trim-polygon scanline accuracy for trimmed faces).
606pub fn solid_mass_properties_full(solid: &BrepSolid) -> Result<FullMassProperties, String> {
607    let base = solid_mass_properties(solid)?;
608    let token = super::profile::begin("solid_mass_properties_full.moments");
609    let result = solid_mass_properties_full_moments(solid, base);
610    super::profile::end(token);
611    result
612}
613
614fn solid_mass_properties_full_moments(
615    solid: &BrepSolid,
616    base: MassProperties,
617) -> Result<FullMassProperties, String> {
618    const MOMENT_KINDS: [Integrand; 9] = [
619        Integrand::MomentX,
620        Integrand::MomentY,
621        Integrand::MomentZ,
622        Integrand::SecondXX,
623        Integrand::SecondYY,
624        Integrand::SecondZZ,
625        Integrand::ProductXY,
626        Integrand::ProductXZ,
627        Integrand::ProductYZ,
628    ];
629    let mut moments = [0.0f64; 3];
630    let mut seconds = [0.0f64; 3];
631    let mut products = [0.0f64; 3];
632    for shell in &solid.shells {
633        for face in &shell.faces {
634            mass_profile(|p| p.faces += 1);
635            let results = if !is_affine(&face.surface)? && !is_untrimmed(face)? {
636                mass_profile(|p| p.trimmed_faces += 1);
637                // One cell decomposition, all nine moment integrands per
638                // station, instead of nine full passes per face.
639                integrate_trimmed_multi(face, &MOMENT_KINDS)?
640            } else {
641                MOMENT_KINDS
642                    .iter()
643                    .map(|kind| face_moment(face, *kind))
644                    .collect::<Result<Vec<_>, _>>()?
645            };
646            moments[0] += results[0];
647            moments[1] += results[1];
648            moments[2] += results[2];
649            seconds[0] += results[3];
650            seconds[1] += results[4];
651            seconds[2] += results[5];
652            products[0] += results[6];
653            products[1] += results[7];
654            products[2] += results[8];
655        }
656    }
657    let volume = base.volume;
658    if volume.abs() <= 1e-30 {
659        return Err("solid_mass_properties_full: non-positive volume".into());
660    }
661    let centroid = Vec3::new(
662        moments[0] / volume,
663        moments[1] / volume,
664        moments[2] / volume,
665    );
666    // Inertia about the origin, then parallel-axis down to the centroid.
667    let ixx =
668        seconds[1] + seconds[2] - volume * (centroid.y * centroid.y + centroid.z * centroid.z);
669    let iyy =
670        seconds[0] + seconds[2] - volume * (centroid.x * centroid.x + centroid.z * centroid.z);
671    let izz =
672        seconds[0] + seconds[1] - volume * (centroid.x * centroid.x + centroid.y * centroid.y);
673    let ixy = -(products[0] - volume * centroid.x * centroid.y);
674    let ixz = -(products[1] - volume * centroid.x * centroid.z);
675    let iyz = -(products[2] - volume * centroid.y * centroid.z);
676    let inertia = [[ixx, ixy, ixz], [ixy, iyy, iyz], [ixz, iyz, izz]];
677    let (principal_moments, principal_axes) = principal_frame(inertia);
678    Ok(FullMassProperties {
679        surface_area: base.surface_area,
680        volume,
681        centroid,
682        inertia,
683        principal_moments,
684        principal_axes,
685    })
686}
687