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 crate::profile::profile_enabled() {
272                    profile_line!("mass.sign_certificate kind=trimmed_spheres lower={lower:.9} bar={bar:.3e}");
273                }
274                return Ok(lower);
275            }
276        }
277    }
278    if sliver_bound_enabled() {
279        if let Some(lower) = sliver_bound_certificate(solid, bar) {
280            return Ok(lower);
281        }
282    }
283    let token = super::profile::begin("solid_signed_volume_for_sign");
284    let outcome = solid_sign_terms_reading(solid);
285    super::profile::end(token);
286    let (reading, magnitude) = outcome?;
287    if (reading - bar).abs() > SIGN_READING_MARGIN * magnitude {
288        return Ok(reading);
289    }
290    solid_signed_volume(solid)
291}
292
293/// `BREP_SIGN_SLIVER_BOUND` (on by default since 2026-11-03,
294/// tier-P promotion; `0`/`off` restores the reading alone): before the sign
295/// reading, try a certificate that integrates every face's cells exactly as
296/// the reading does but skips the surface stations of its chord corrections,
297/// bounding them instead (see [`sliver_bound_certificate`]). Read on every
298/// call.
299fn sliver_bound_enabled() -> bool {
300    switch!(live "BREP_SIGN_SLIVER_BOUND", true)
301}
302
303
304/// A lower bound on the sign reading that proves its verdict, or `None`.
305///
306/// The reading sums, per face, the cells' quadrature plus the chord
307/// corrections' quadrature `Σ w_k f(Q_k)` (`trimmed::curved_boundary_correction`,
308/// positive Gauss weights times the sliver jacobian). Its correction is at most
309/// `Σ |w_k| · sup |f|`: the first factor is the face's sliver measure
310/// (`trimmed::with_sliver_measure`, read from the pcurves alone), the second
311/// comes from the carrier's control net ([`surface_flux_bounds`]): the volume
312/// integrand `(P - local) · N` is at most `r · n`, the vector area's `N` at most
313/// `n`, so a face's contribution about the shell reference moves by at most
314/// `measure · n · (r + |local - reference|) / 3`. Faces the reading already
315/// holds exact terms for are taken as they are (bound zero); faces whose route
316/// reads its own corrections are integrated exactly, as the reading does.
317///
318/// The certificate stands only when the bounded sum clears the bar by the
319/// reading's own margin, with every bound and a rounding slack added to the
320/// magnitude: then the reading would return a value above the bar without its
321/// exact fallback, and this returns a lower bound on that value. Otherwise
322/// `None`, and the reading runs as before (an error anywhere also yields
323/// `None`, so the reading reports it).
324pub(super) fn sliver_bound_certificate(solid: &BrepSolid, bar: f64) -> Option<f64> {
325    if !bar.is_finite() {
326        return None;
327    }
328    let token = super::profile::begin("solid_sign_sliver_bound");
329    let outcome = super::trimmed::with_straight_corrections_skipped(|| -> Option<(f64, f64, f64, f64, [usize; 3])> {
330        let (mut reading, mut magnitude, mut bound, mut scale) = (0.0, 0.0, 0.0, 0.0);
331        let mut counts = [0usize; 3]; // exact terms held, bounded, integrated exactly
332        for shell in &solid.shells {
333            let reference = shell_volume_reference(shell).ok()?;
334            let (mut volume, mut compensation) = (0.0f64, 0.0f64);
335            for face in &shell.faces {
336                let local = face_volume_reference(face).unwrap_or(reference);
337                let identity = super::identity::face_contribution_identity(face, local);
338                let offset = local.sub(reference);
339                let (terms, face_bound, face_scale) = match crate::reading_memo::peek_sign_terms(identity) {
340                    Some(terms) => {
341                        counts[0] += 1;
342                        (terms?, 0.0, 0.0)
343                    }
344                    None => match crate::reading_memo::memoized_bounded_sign_terms(identity, || bounded_face_terms(face, local)) {
345                        Some((terms, per_offset, fixed, rounding)) => {
346                            counts[1] += 1;
347                            (terms, fixed + per_offset * offset.length(), rounding)
348                        }
349                        None => {
350                            counts[2] += 1;
351                            let terms = crate::reading_memo::memoized_sign_terms(identity, || {
352                                face_volume_terms_about(face, local).ok()
353                            })?;
354                            (terms, 0.0, 0.0)
355                        }
356                    },
357                };
358                let area = Vec3::new(terms[1], terms[2], terms[3]);
359                let contribution = terms[0] + offset.dot(area);
360                magnitude += contribution.abs();
361                bound += face_bound;
362                scale += terms[0].abs() + offset.length() * area.length() + face_bound + face_scale * (1.0 + offset.length());
363                let next = volume + contribution;
364                if volume.abs() >= contribution.abs() {
365                    compensation += (volume - next) + contribution;
366                } else {
367                    compensation += (contribution - next) + volume;
368                }
369                volume = next;
370            }
371            reading += volume + compensation;
372        }
373        Some((reading, magnitude, bound, scale, counts))
374    });
375    super::profile::end(token);
376    let (reading, magnitude, bound, scale, counts) = outcome?;
377    // Rounding: the reading adds each correction station to a running total
378    // the cells started, and the certificate does not; a face's stations number
379    // well under 1e6, so their re-association moves a slot by far less than
380    // 1e-9 of the face's scale.
381    let slack = 1e-9 * scale;
382    let lower = reading - bound - slack;
383    let certified = [reading, magnitude, bound, scale, lower].iter().all(|v| v.is_finite())
384        && lower > bar
385        && lower - bar > SIGN_READING_MARGIN * (magnitude + bound + slack);
386    if crate::profile::profile_enabled() {
387        profile_line!(
388            "mass.sign_certificate kind=sliver_bound certified={certified} cells={reading:.9e} bound={bound:.3e} slack={slack:.3e} magnitude={magnitude:.3e} held={} bounded={} exact={}",
389            counts[0], counts[1], counts[2]
390        );
391    }
392    certified.then_some(lower)
393}
394
395/// One face's sign terms with the chord corrections bounded, as
396/// `(terms, per_offset, fixed, rounding)`: the face's contribution about a
397/// shell reference `ref` lies within `fixed + per_offset · |local - ref|` of
398/// the reading's, and `rounding` is a scale for the certificate's slack.
399/// `None` when the face's route reads its own corrections, a bound is not
400/// finite, or the integration fails (the reading then integrates it).
401pub(super) fn bounded_face_terms(face: &FaceRecord, local: Vec3) -> Option<([f64; 4], f64, f64, f64)> {
402    let (terms, measure) = super::trimmed::with_sliver_measure(|| face_volume_terms_about(face, local));
403    let terms = terms.ok()?;
404    if measure.declined || !terms.iter().all(|v| v.is_finite()) {
405        return None;
406    }
407    if measure.measure == 0.0 {
408        return Some((terms, 0.0, 0.0, measure.complement / 3.0));
409    }
410    let (n, r) = surface_flux_bounds(&face.surface, local)?;
411    let per_offset = measure.measure * n / 3.0;
412    let fixed = per_offset * r;
413    [per_offset, fixed].iter().all(|v| v.is_finite()).then_some((terms, per_offset, fixed, measure.complement / 3.0))
414}
415
416/// Bounds from the control net of `surface`, valid over its whole domain (and
417/// its periodic wraps): `n >= |S_u × S_v|` and `r >= |S - center|`.
418///
419/// With homogeneous controls `Q = (w (P - center), w)`, all weights positive:
420/// `S - center` is a convex combination of the `P - center`, so `r` is their
421/// largest length; the homogeneous derivative `A_u` is a convex combination
422/// of `p (Q_{i+1} - Q_i) / (u_{i+p+1} - u_{i+1})` (spline differentiation,
423/// partition of unity of the degree-lowered basis), so its point part and its
424/// weight part are at most the largest lengths of those differences' parts;
425/// and `S_u = (X_u - w_u (S - center)) / w` with `w >= ` the smallest weight.
426/// `None` for a non-positive or non-finite weight or control.
427pub(super) fn surface_flux_bounds(surface: &crate::NurbsSurface, center: Vec3) -> Option<(f64, f64)> {
428    let net = &surface.control_points;
429    let rows = net.len();
430    let columns = net.first()?.len();
431    if rows == 0 || columns == 0 || net.iter().any(|row| row.len() != columns) {
432        return None;
433    }
434    let relative = |i: usize, j: usize| -> Option<[f64; 4]> {
435        let q = net[i][j];
436        let values = [q.x - center.x * q.w, q.y - center.y * q.w, q.z - center.z * q.w, q.w];
437        (values.iter().all(|v| v.is_finite()) && q.w > 0.0).then_some(values)
438    };
439    let mut w_min = f64::INFINITY;
440    let mut r = 0.0f64;
441    for i in 0..rows {
442        for j in 0..columns {
443            let q = relative(i, j)?;
444            w_min = w_min.min(q[3]);
445            r = r.max((q[0] * q[0] + q[1] * q[1] + q[2] * q[2]).sqrt() / q[3]);
446        }
447    }
448    // (largest |X_d|, largest |w_d|) in one parameter direction.
449    let derivative = |along_u: bool| -> Option<(f64, f64)> {
450        let (degree, knots, count) = if along_u {
451            (surface.degree_u, &surface.knots_u, rows)
452        } else {
453            (surface.degree_v, &surface.knots_v, columns)
454        };
455        if knots.len() < count + degree + 1 {
456            return None;
457        }
458        let (mut point, mut weight) = (0.0f64, 0.0f64);
459        if degree == 0 {
460            return Some((0.0, 0.0));
461        }
462        for k in 0..count.saturating_sub(1) {
463            let span = knots[k + degree + 1] - knots[k + 1];
464            if !(span > 0.0) {
465                continue;
466            }
467            let factor = degree as f64 / span;
468            let others = if along_u { columns } else { rows };
469            for other in 0..others {
470                let (a, b) = if along_u {
471                    (relative(k, other)?, relative(k + 1, other)?)
472                } else {
473                    (relative(other, k)?, relative(other, k + 1)?)
474                };
475                let d = [b[0] - a[0], b[1] - a[1], b[2] - a[2], b[3] - a[3]];
476                point = point.max(factor * (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt());
477                weight = weight.max(factor * d[3].abs());
478            }
479        }
480        Some((point, weight))
481    };
482    let (xu, wu) = derivative(true)?;
483    let (xv, wv) = derivative(false)?;
484    let su = (xu + wu * r) / w_min;
485    let sv = (xv + wv * r) / w_min;
486    let n = su * sv;
487    // A little headroom for the evaluator's own rounding.
488    let (n, r) = (n * (1.0 + 1e-9), r * (1.0 + 1e-9));
489    (n.is_finite() && r.is_finite() && w_min.is_finite()).then_some((n, r))
490}
491
492/// Retain the exact path's shell references and compensated face order.
493/// Each face's volume flux is affine in the reference: retain its local
494/// volume and vector area, then translate the term to the shell reference.
495/// Unchanged faces survive both id renumbering and a moving shell reference.
496fn solid_sign_terms_reading(solid: &BrepSolid) -> Result<(f64, f64), String> {
497    super::trimmed::with_straight_corrections_skipped(|| {
498        let (mut reading, mut magnitude) = (0.0, 0.0);
499        for (shell_index, shell) in solid.shells.iter().enumerate() {
500            let (volume, faces) = shell_volume_sign_reading(shell, shell_index)?;
501            reading += volume;
502            magnitude += faces;
503        }
504        Ok((reading, magnitude))
505    })
506}
507
508fn sign_terms_oracle_enabled() -> bool {
509    switch!(live "BREP_SIGN_TERMS_ORACLE", false)
510}
511
512/// Diagnostic census only; the shipped reading and the sphere certificate
513/// stay unchanged. The cold oracle never uses the mass cache or reading memo.
514fn verify_solid_sign_terms(solid: &BrepSolid) -> Result<(), String> {
515    let token = super::profile::begin("solid_sign_terms_oracle");
516    let reading = solid_sign_terms_reading(solid).map(|pair| pair.0);
517    super::profile::end(token);
518    check_sign_terms(solid, reading?)
519}
520
521pub(super) fn check_sign_terms(solid: &BrepSolid, reading: f64) -> Result<(), String> {
522    let exact = solid_signed_volume_uncached(solid)?;
523    let error = (reading - exact).abs();
524    let bar = 1e-9 * exact.abs();
525    if !reading.is_finite() || !exact.is_finite() || error > bar {
526        return Err(format!("sign terms oracle: retained {reading:.17e}, whole-body {exact:.17e}, error {error:.3e} exceeds {bar:.3e}"));
527    }
528    super::profile::emit_sign_oracle(solid, reading, exact)?;
529    Ok(())
530}
531
532/// [`shell_signed_volume`]'s sum under the caller's correction policy, with
533/// the sum of the face contributions' magnitudes beside it (the scale the
534/// sign reading's margin is read against).
535fn shell_volume_sign_reading(shell: &ShellRecord, shell_index: usize) -> Result<(f64, f64), String> {
536    let reference = shell_volume_reference(shell)?;
537    if crate::profile::profile_enabled() {
538        profile_line!(
539            "mass.sign_reference faces={} reference=({:.9},{:.9},{:.9})",
540            shell.faces.len(), reference.x, reference.y, reference.z
541        );
542    }
543    let mut volume = 0.0;
544    let mut compensation = 0.0;
545    let mut magnitude = 0.0;
546    let face_lines = super::profile::face_lines_enabled();
547    for (face_index, face) in shell.faces.iter().enumerate() {
548        mass_profile(|p| p.faces += 1);
549        let line_before = face_lines.then(super::profile::snapshot);
550        let line_started = face_lines.then(Instant::now);
551        // V_f(r) = V_f(a) + (a-r) · A_f / 3. Integrate both terms at
552        // the SAME quadrature stations, including trim corrections. Using a
553        // boundary-only vector area would miss the integrator's numerical
554        // residual and could change the sign reading on inherited trims.
555        // Anchor each face locally for accuracy far from the world origin.
556        let local = face_volume_reference(face).unwrap_or(reference);
557        let hash_started = super::profile::profile_started();
558        let identity = super::identity::face_contribution_identity(face, local);
559        let hash_ms = super::profile::elapsed_ms(hash_started);
560        let mut reread = false;
561        let terms = match crate::reading_memo::memoized_sign_terms(identity, || {
562            reread = true;
563            face_volume_terms_about(face, local).ok()
564        }) {
565            Some(terms) => terms,
566            None => face_volume_terms_about(face, local)?,
567        };
568        let contribution = terms[0] + local.sub(reference).dot(Vec3::new(terms[1], terms[2], terms[3]));
569        super::profile::sign_face(shell_index, face, reread, hash_ms);
570        emit_face_line("shell_volume_sign_reading", shell_index, face_index, face, line_before, line_started);
571        magnitude += contribution.abs();
572        let next = volume + contribution;
573        if volume.abs() >= contribution.abs() {
574            compensation += (volume - next) + contribution;
575        } else {
576            compensation += (contribution - next) + volume;
577        }
578        volume = next;
579    }
580    Ok((volume + compensation, magnitude))
581}
582
583fn solid_signed_volume_uncached(solid: &BrepSolid) -> Result<f64, String> {
584    let mut volume = 0.0;
585    for shell in &solid.shells {
586        volume += shell_signed_volume(shell)?;
587    }
588    Ok(volume)
589}
590
591pub(super) fn shell_volume_reference(shell: &ShellRecord) -> Result<Vec3, String> {
592    shell.faces.iter().find_map(face_volume_reference)
593        .ok_or_else(|| "mass_properties: shell has no finite geometric reference".to_string())
594}
595
596/// One finite authored point, with the same fallbacks as the exact integral.
597pub(super) fn face_volume_reference(face: &FaceRecord) -> Option<Vec3> {
598    let finite = |point: Vec3| point.x.is_finite() && point.y.is_finite() && point.z.is_finite();
599    if let Some(coedge) = face
600        .loops
601        .first()
602        .and_then(|loop_record| loop_record.coedges.first())
603    {
604        if let Ok(domain) = coedge.pcurve.domain() {
605            if let Ok(uv) = coedge.pcurve.evaluate(domain[0]) {
606                if let Ok(point) = face.surface.evaluate(uv.x, uv.y) {
607                    if finite(point) {
608                        return Some(point);
609                    }
610                }
611            }
612        }
613    }
614    if let Ok((u_breaks, v_breaks)) = surface_breaks(&face.surface) {
615        let u = 0.5 * (u_breaks[0] + u_breaks[u_breaks.len() - 1]);
616        let v = 0.5 * (v_breaks[0] + v_breaks[v_breaks.len() - 1]);
617        if let Ok(point) = face.surface.evaluate(u, v) {
618            if finite(point) {
619                return Some(point);
620            }
621        }
622    }
623    for row in &face.surface.control_points {
624        for control in row {
625            if let Ok(point) = control.point() {
626                if finite(point) {
627                    return Some(point);
628                }
629            }
630        }
631    }
632    None
633}
634
635/// Exact signed volume of one closed shell. Multi-shell tessellation uses this
636/// to preserve the authored material orientation: exterior shells contribute
637/// positively, while a void boundary contributes negatively.
638pub(crate) fn shell_signed_volume(shell: &ShellRecord) -> Result<f64, String> {
639    let reference = shell_volume_reference(shell)?;
640
641    // A closed shell's divergence-theorem volume is independent of origin,
642    // but evaluating each face about the world origin can cancel enormous
643    // translated face terms down to a tiny cavity volume. Anchor the
644    // integrand on an authored boundary point and compensate the remaining
645    // face sum so the sign is stable for small, far-translated shells.
646    let mut volume = 0.0;
647    let mut compensation = 0.0;
648    let face_lines = super::profile::face_lines_enabled();
649    for (face_index, face) in shell.faces.iter().enumerate() {
650        mass_profile(|p| p.faces += 1);
651        let line_before = face_lines.then(super::profile::snapshot);
652        let line_started = face_lines.then(Instant::now);
653        let contribution = face_volume_contribution_about(face, reference)?;
654        emit_face_line(
655            "shell_signed_volume",
656            0,
657            face_index,
658            face,
659            line_before,
660            line_started,
661        );
662        let next = volume + contribution;
663        if volume.abs() >= contribution.abs() {
664            compensation += (volume - next) + contribution;
665        } else {
666            compensation += (contribution - next) + volume;
667        }
668        volume = next;
669    }
670    Ok(volume + compensation)
671}
672
673/// Exact moments for affine faces via Green's theorem over the trim
674/// pcurves: ∬ g du dv = ∮ G dv with G(u,v) = ∫ g dt.  On an affine carrier
675/// every moment integrand is a low-degree polynomial, so the inner Gauss
676/// antiderivative is exact and the boundary quadrature has the same quality
677/// as `parameter_space_area` — no trim-polygon sampling error.  The loop
678/// winding supplies the orientation sign, matching the affine volume path.
679pub(super) fn affine_moment(face: &FaceRecord, kind: Integrand) -> Result<f64, String> {
680    let ku = crate::KnotVector::new(face.surface.knots_u.clone(), face.surface.degree_u)?;
681    let kv = crate::KnotVector::new(face.surface.knots_v.clone(), face.surface.degree_v)?;
682    let [u0, u1] = ku.domain();
683    let [v0, v1] = kv.domain();
684    let points = &face.surface.control_points;
685    let p00 = points[0][0].point()?;
686    let p10 = points[1][0].point()?;
687    let p01 = points[0][1].point()?;
688    let du = p10.sub(p00).scale(1.0 / (u1 - u0));
689    let dv = p01.sub(p00).scale(1.0 / (v1 - v0));
690    let weighted_normal = du.cross(dv);
691    let g = |u: f64, v: f64| {
692        let point = p00.add(du.scale(u - u0)).add(dv.scale(v - v0));
693        integrand_value(kind, point, weighted_normal)
694    };
695    let inner = |u: f64, v: f64| {
696        let half = (u - u0) * 0.5;
697        let middle = (u + u0) * 0.5;
698        let mut sum = 0.0;
699        for index in 0..GAUSS_X.len() {
700            sum += GAUSS_W[index] * g(middle + half * GAUSS_X[index], v);
701        }
702        sum * half
703    };
704    let mut total = 0.0;
705    for loop_record in &face.loops {
706        for coedge in &loop_record.coedges {
707            for pair in curve_breaks(&coedge.pcurve)?.windows(2) {
708                // The inner antiderivative is a polynomial in u on an affine
709                // carrier and is exact at any order; the boundary walk is the
710                // pcurve's own, so it takes the pcurve's panels (`rule`).
711                for panel in rule::curve_panels(&coedge.pcurve, pair[0], pair[1])? {
712                    for (parameter, weight) in panel.stations() {
713                        let (point, tangent) = coedge.pcurve.deriv1(parameter)?;
714                        total += weight * inner(point.x, point.y) * tangent.y;
715                    }
716                }
717            }
718        }
719    }
720    // The loops closed as the area closes them (`closed_parameter_space_area`):
721    // an open boundary integral moves with the uv origin. Along a straight
722    // chord the integrand is a polynomial of degree at most four, which the
723    // eight-station rule integrates exactly.
724    for (end, start) in loop_closing_chords(face)? {
725        let step = start.y - end.y;
726        if step == 0.0 {
727            continue;
728        }
729        for index in 0..GAUSS_X.len() {
730            let fraction = 0.5 * (GAUSS_X[index] + 1.0);
731            let u = end.x + (start.x - end.x) * fraction;
732            let v = end.y + step * fraction;
733            total += 0.5 * GAUSS_W[index] * inner(u, v) * step;
734        }
735    }
736    Ok(total)
737}
738
739pub(super) fn face_moment(face: &FaceRecord, kind: Integrand) -> Result<f64, String> {
740    if is_affine(&face.surface)? {
741        return affine_moment(face, kind);
742    }
743    if let Some(values) = biperiodic_band_integral(face, &[kind])? {
744        return Ok(values[0]);
745    }
746    if is_untrimmed(face)? {
747        integrate_untrimmed(face, kind)
748    } else {
749        integrate_trimmed(face, kind)
750    }
751}
752
753/// Symmetric-matrix product `A[i][j] = M[k][i] * M[k][j]` reused by the Jacobi
754/// sweep — kept tiny and explicit rather than pulling in a matrix crate.
755pub(super) fn mat3_mul(a: [[f64; 3]; 3], b: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
756    let mut r = [[0.0f64; 3]; 3];
757    for i in 0..3 {
758        for j in 0..3 {
759            for k in 0..3 {
760                r[i][j] += a[i][k] * b[k][j];
761            }
762        }
763    }
764    r
765}
766
767pub(super) fn mat3_transpose(a: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
768    let mut r = [[0.0f64; 3]; 3];
769    for i in 0..3 {
770        for j in 0..3 {
771            r[i][j] = a[j][i];
772        }
773    }
774    r
775}
776
777/// Classic Jacobi eigenvalue iteration for a SYMMETRIC 3×3 matrix. Repeatedly
778/// applies a Givens rotation in the plane of the largest off-diagonal element,
779/// each rotation zeroing that element, until the matrix is diagonal to machine
780/// precision. Returns `(eigenvalues, vectors)` where `vectors` holds the
781/// eigenvectors as COLUMNS (`vectors[i][k]` is component `i` of eigenvector
782/// `k`), unsorted. For a symmetric matrix Jacobi is unconditionally
783/// convergent and the accumulated rotations stay orthonormal, so the columns
784/// are mutually orthonormal by construction.
785pub(super) fn jacobi_eigen_symmetric_3x3(matrix: [[f64; 3]; 3]) -> ([f64; 3], [[f64; 3]; 3]) {
786    // Symmetrize defensively against tiny asymmetry from round-off upstream.
787    let mut a = [
788        [matrix[0][0], 0.0, 0.0],
789        [0.0, matrix[1][1], 0.0],
790        [0.0, 0.0, matrix[2][2]],
791    ];
792    a[0][1] = 0.5 * (matrix[0][1] + matrix[1][0]);
793    a[1][0] = a[0][1];
794    a[0][2] = 0.5 * (matrix[0][2] + matrix[2][0]);
795    a[2][0] = a[0][2];
796    a[1][2] = 0.5 * (matrix[1][2] + matrix[2][1]);
797    a[2][1] = a[1][2];
798
799    let mut v = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
800    let scale = a[0][0].abs() + a[1][1].abs() + a[2][2].abs() + 1.0;
801    for _sweep in 0..64 {
802        // Pick the largest off-diagonal magnitude.
803        let pairs = [(0usize, 1usize), (0, 2), (1, 2)];
804        let (mut p, mut q, mut best) = (0usize, 1usize, 0.0f64);
805        for &(i, j) in &pairs {
806            if a[i][j].abs() > best {
807                best = a[i][j].abs();
808                p = i;
809                q = j;
810            }
811        }
812        if best <= 1e-18 * scale {
813            break;
814        }
815        // Angle that zeroes a[p][q]: t = tan(theta) is the smaller root of
816        // t² + 2·theta·t − 1 = 0 with theta = (a_qq − a_pp)/(2 a_pq).
817        let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
818        let t = if theta == 0.0 {
819            1.0
820        } else {
821            theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt())
822        };
823        let c = 1.0 / (t * t + 1.0).sqrt();
824        let s = t * c;
825        // Givens rotation J: J[p][p]=J[q][q]=c, J[p][q]=s, J[q][p]=−s.
826        let mut j = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
827        j[p][p] = c;
828        j[q][q] = c;
829        j[p][q] = s;
830        j[q][p] = -s;
831        // A ← Jᵀ A J   (drives a[p][q] to zero), V ← V J.
832        a = mat3_mul(mat3_transpose(j), mat3_mul(a, j));
833        v = mat3_mul(v, j);
834    }
835    ([a[0][0], a[1][1], a[2][2]], v)
836}
837
838/// Principal axes/moments from a centroidal inertia tensor (Golovanov §8.11).
839/// Diagonalizes the symmetric tensor, sorts the eigenpairs by moment
840/// ASCENDING, returns each eigenvector as a ROW (`axes[i]` pairs with
841/// `moments[i]`) normalized to unit length, and fixes the sign of the third
842/// axis so the frame is right-handed (determinant +1).
843pub(super) fn principal_frame(inertia: [[f64; 3]; 3]) -> ([f64; 3], [[f64; 3]; 3]) {
844    let (values, vectors) = jacobi_eigen_symmetric_3x3(inertia);
845    // Column k of `vectors` is the eigenvector for `values[k]`.
846    let mut order = [0usize, 1, 2];
847    order.sort_by(|&a, &b| values[a].total_cmp(&values[b]));
848    let mut moments = [0.0f64; 3];
849    let mut axes = [[0.0f64; 3]; 3];
850    for (slot, &k) in order.iter().enumerate() {
851        moments[slot] = values[k];
852        let mut axis = [vectors[0][k], vectors[1][k], vectors[2][k]];
853        let length = (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt();
854        if length > 0.0 {
855            axis = [axis[0] / length, axis[1] / length, axis[2] / length];
856        }
857        axes[slot] = axis;
858    }
859    // Right-hand the frame: if axis0 × axis1 points opposite axis2, flip axis2.
860    let cross = [
861        axes[0][1] * axes[1][2] - axes[0][2] * axes[1][1],
862        axes[0][2] * axes[1][0] - axes[0][0] * axes[1][2],
863        axes[0][0] * axes[1][1] - axes[0][1] * axes[1][0],
864    ];
865    let det = cross[0] * axes[2][0] + cross[1] * axes[2][1] + cross[2] * axes[2][2];
866    if det < 0.0 {
867        axes[2] = [-axes[2][0], -axes[2][1], -axes[2][2]];
868    }
869    (moments, axes)
870}
871
872/// Area, volume, centroid, and centroidal inertia (unit density).  Area and
873/// volume use the same exact paths as `solid_mass_properties`; the moment
874/// integrals use divergence-theorem surface quadrature (exact for untrimmed
875/// spans, trim-polygon scanline accuracy for trimmed faces).
876pub fn solid_mass_properties_full(solid: &BrepSolid) -> Result<FullMassProperties, String> {
877    let base = solid_mass_properties(solid)?;
878    let token = super::profile::begin("solid_mass_properties_full.moments");
879    let result = solid_mass_properties_full_moments(solid, base);
880    super::profile::end(token);
881    result
882}
883
884fn solid_mass_properties_full_moments(
885    solid: &BrepSolid,
886    base: MassProperties,
887) -> Result<FullMassProperties, String> {
888    const MOMENT_KINDS: [Integrand; 9] = [
889        Integrand::MomentX,
890        Integrand::MomentY,
891        Integrand::MomentZ,
892        Integrand::SecondXX,
893        Integrand::SecondYY,
894        Integrand::SecondZZ,
895        Integrand::ProductXY,
896        Integrand::ProductXZ,
897        Integrand::ProductYZ,
898    ];
899    let mut moments = [0.0f64; 3];
900    let mut seconds = [0.0f64; 3];
901    let mut products = [0.0f64; 3];
902    for shell in &solid.shells {
903        for face in &shell.faces {
904            mass_profile(|p| p.faces += 1);
905            let results = if !is_affine(&face.surface)? && !is_untrimmed(face)? {
906                mass_profile(|p| p.trimmed_faces += 1);
907                // One cell decomposition, all nine moment integrands per
908                // station, instead of nine full passes per face.
909                integrate_trimmed_multi(face, &MOMENT_KINDS)?
910            } else {
911                MOMENT_KINDS
912                    .iter()
913                    .map(|kind| face_moment(face, *kind))
914                    .collect::<Result<Vec<_>, _>>()?
915            };
916            moments[0] += results[0];
917            moments[1] += results[1];
918            moments[2] += results[2];
919            seconds[0] += results[3];
920            seconds[1] += results[4];
921            seconds[2] += results[5];
922            products[0] += results[6];
923            products[1] += results[7];
924            products[2] += results[8];
925        }
926    }
927    let volume = base.volume;
928    if volume.abs() <= 1e-30 {
929        return Err("solid_mass_properties_full: non-positive volume".into());
930    }
931    let centroid = Vec3::new(
932        moments[0] / volume,
933        moments[1] / volume,
934        moments[2] / volume,
935    );
936    // Inertia about the origin, then parallel-axis down to the centroid.
937    let ixx =
938        seconds[1] + seconds[2] - volume * (centroid.y * centroid.y + centroid.z * centroid.z);
939    let iyy =
940        seconds[0] + seconds[2] - volume * (centroid.x * centroid.x + centroid.z * centroid.z);
941    let izz =
942        seconds[0] + seconds[1] - volume * (centroid.x * centroid.x + centroid.y * centroid.y);
943    let ixy = -(products[0] - volume * centroid.x * centroid.y);
944    let ixz = -(products[1] - volume * centroid.x * centroid.z);
945    let iyz = -(products[2] - volume * centroid.y * centroid.z);
946    let inertia = [[ixx, ixy, ixz], [ixy, iyy, iyz], [ixz, iyz, izz]];
947    let (principal_moments, principal_axes) = principal_frame(inertia);
948    Ok(FullMassProperties {
949        surface_area: base.surface_area,
950        volume,
951        centroid,
952        inertia,
953        principal_moments,
954        principal_axes,
955    })
956}
957
958