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