Skip to main content

brep_kernel/props/mass_properties/
integration.rs

1pub(super) use crate::curve::interior_knots;
2use super::*;
3
4pub(crate) fn curve_breaks(curve: &NurbsCurve) -> Result<Vec<f64>, String> {
5    let [start, end] = curve.domain()?;
6    let mut result = vec![start];
7    result.extend(interior_knots(&curve.knots, curve.degree));
8    result.push(end);
9    Ok(result)
10}
11
12pub fn parameter_space_area(face: &FaceRecord) -> Result<f64, String> {
13    let mut area = 0.0;
14    for loop_record in &face.loops {
15        for coedge in &loop_record.coedges {
16            for pair in curve_breaks(&coedge.pcurve)?.windows(2) {
17                for panel in rule::curve_panels(&coedge.pcurve, pair[0], pair[1])? {
18                    for (parameter, weight) in panel.stations() {
19                        let (point, tangent) = coedge.pcurve.deriv1(parameter)?;
20                        area += weight * 0.5 * (point.x * tangent.y - point.y * tangent.x);
21                    }
22                }
23            }
24        }
25    }
26    Ok(area)
27}
28
29/// The escape hatch for [`closed_parameter_space_area`]: `BREP_OPEN_PLANAR_LOOPS=1`
30/// reads every planar face's trim loops open again, as they were read before
31/// the closing chords, so the two readings are two cells of one binary.
32fn open_planar_loops() -> bool {
33    debug_flag!("BREP_OPEN_PLANAR_LOOPS")
34}
35
36/// Every straight uv chord that closes a trim loop of `face`: from where one
37/// coedge's pcurve ends to where the next one's begins, the last back to the
38/// first. A loop whose pcurves meet contributes chords of zero length.
39pub(super) fn loop_closing_chords(face: &FaceRecord) -> Result<Vec<(Vec3, Vec3)>, String> {
40    let mut chords = Vec::new();
41    if open_planar_loops() {
42        return Ok(chords);
43    }
44    for loop_record in &face.loops {
45        let count = loop_record.coedges.len();
46        for index in 0..count {
47            let [_, end] = loop_record.coedges[index].pcurve.domain()?;
48            let [start, _] = loop_record.coedges[(index + 1) % count].pcurve.domain()?;
49            chords.push((
50                loop_record.coedges[index].pcurve.evaluate(end)?,
51                loop_record.coedges[(index + 1) % count].pcurve.evaluate(start)?,
52            ));
53        }
54    }
55    Ok(chords)
56}
57
58/// [`parameter_space_area`] with every trim loop CLOSED by its chords
59/// ([`loop_closing_chords`]). The mass lane of a planar face reads its area
60/// here.
61///
62/// `½∮(u dv − v du)` is independent of where the uv origin sits only over a
63/// closed path. Summed over pcurves whose ends miss one another by `δ`, it
64/// reads `½(e × δ)` too much or too little at each joint `e`, so the same
65/// region read about a translated parameterization gives a different area.
66/// The offset shell's planar faces carry such joints — the pcurve ends of the
67/// box-bore-sphere shells' opening cork miss their vertices by up to 5.6e-5
68/// while the 3D vertices are exact — and on `offsetShellProblem` that alone
69/// put the cork's area 6.680e-4 short and the solid's volume 4.453e-3 short of
70/// a kernel-free integral. Closed, the same face reads within 1e-8 of it.
71///
72/// The chord closes what the face record names. The curved lane's trim
73/// polygons close the same way, implicitly, which is why a curved face with
74/// the same gaps already read true. [`parameter_space_area`] itself stays
75/// OPEN: its other callers read its SIGN as a loop's winding, and on a
76/// periodic carrier a loop that wraps once is not closed in uv by design.
77pub(crate) fn closed_parameter_space_area(face: &FaceRecord) -> Result<f64, String> {
78    let mut area = parameter_space_area(face)?;
79    for (end, start) in loop_closing_chords(face)? {
80        area += 0.5 * (crate::polygon::area_term([end.x, end.y], [start.x, start.y]));
81    }
82    Ok(area)
83}
84
85/// The fraction of a face's own extent within which two parameter points are the SAME
86/// point of the carrier. It separates a SEAM — where a loop steps from one edge of the
87/// domain to the other and both parameters name one 3D point, so their evaluations agree
88/// to floating-point rounding — from a trim that misses its own joint.
89///
90/// Set from the separation the population shows, not picked: across 92,000 readings of
91/// this kernel's corpus and both suites, a seam or pole joint's two images agree to
92/// ~1e-14 of the face's extent, while the smallest real miss measured is 1.06e-7 — five
93/// orders above this bar, and a hundred above double-precision evaluation noise.
94const SEAM_COINCIDENCE_REL: f64 = 1e-12;
95
96/// [`parameter_space_area`] with every joint that MISSES closed by a chord, and every
97/// joint that steps across a SEAM left open — the reading a caller on a carrier that may
98/// be periodic or have a pole needs.
99///
100/// The affine lane closes every joint ([`closed_parameter_space_area`]) because a plane has
101/// neither. A general carrier has both: a loop that wraps a closed direction steps a whole
102/// period at one joint by design, and a chord there cancels the sweep — measured on the lib
103/// suite, such a reading collapses to 5e-17 — while a pole's degenerate edge steps a whole
104/// domain edge that is one point. Both are recognised the same way, and by GEOMETRY rather
105/// than by a flag: the two parameters name one point or they do not. `closed_directions()`
106/// is analytic-keyed and answers `false` for a FITTED periodic patch, which is exactly the
107/// carrier a flag-keyed test would mistake.
108///
109/// Nothing is evaluated for a joint whose pcurves meet exactly, which is all but 216 of the
110/// 15,443 calls one corpus replay makes through here.
111pub(crate) fn closed_parameter_space_area_on_carrier(face: &FaceRecord) -> Result<f64, String> {
112    let mut area = parameter_space_area(face)?;
113    let mut coincidence: Option<f64> = None;
114    for loop_record in &face.loops {
115        let count = loop_record.coedges.len();
116        for index in 0..count {
117            let [_, end] = loop_record.coedges[index].pcurve.domain()?;
118            let [start, _] = loop_record.coedges[(index + 1) % count].pcurve.domain()?;
119            let here = loop_record.coedges[index].pcurve.evaluate(end)?;
120            let next = loop_record.coedges[(index + 1) % count].pcurve.evaluate(start)?;
121            if here.x == next.x && here.y == next.y {
122                continue;
123            }
124            let bar = *coincidence.get_or_insert_with(|| {
125                SEAM_COINCIDENCE_REL
126                    * crate::tolerance::model_scale(
127                        face.surface
128                            .control_points
129                            .iter()
130                            .flatten()
131                            .filter_map(|control| control.point().ok()),
132                    )
133            });
134            let (image_here, image_next) = (
135                face.surface.evaluate_extended(here.x, here.y)?,
136                face.surface.evaluate_extended(next.x, next.y)?,
137            );
138            if image_here.sub(image_next).length() <= bar {
139                continue;
140            }
141            area += 0.5 * (crate::polygon::area_term([here.x, here.y], [next.x, next.y]));
142        }
143    }
144    Ok(area)
145}
146
147pub(super) fn is_affine(surface: &NurbsSurface) -> Result<bool, String> {
148    // Keep exact metric shortcuts consistent with geometry recognition.
149    surface.is_affine()
150}
151
152pub(crate) fn surface_breaks(surface: &NurbsSurface) -> Result<(Vec<f64>, Vec<f64>), String> {
153    let ku = crate::KnotVector::new(surface.knots_u.clone(), surface.degree_u)?;
154    let kv = crate::KnotVector::new(surface.knots_v.clone(), surface.degree_v)?;
155    let [u0, u1] = ku.domain();
156    let [v0, v1] = kv.domain();
157    let mut u = vec![u0];
158    u.extend(interior_knots(&surface.knots_u, surface.degree_u));
159    u.push(u1);
160    let mut v = vec![v0];
161    v.extend(interior_knots(&surface.knots_v, surface.degree_v));
162    v.push(v1);
163    Ok((u, v))
164}
165
166pub(super) fn integrand_value(kind: Integrand, point: Vec3, weighted_normal: Vec3) -> f64 {
167    let (x, y, z) = (point.x, point.y, point.z);
168    match kind {
169        Integrand::Area => weighted_normal.length(),
170        Integrand::Volume => point.dot(weighted_normal),
171        Integrand::VolumeAbout(reference) => point.sub(reference).dot(weighted_normal),
172        Integrand::NormalX => weighted_normal.x,
173        Integrand::NormalY => weighted_normal.y,
174        Integrand::NormalZ => weighted_normal.z,
175        Integrand::MomentX => 0.5 * x * x * weighted_normal.x,
176        Integrand::MomentY => 0.5 * y * y * weighted_normal.y,
177        Integrand::MomentZ => 0.5 * z * z * weighted_normal.z,
178        Integrand::SecondXX => x * x * x / 3.0 * weighted_normal.x,
179        Integrand::SecondYY => y * y * y / 3.0 * weighted_normal.y,
180        Integrand::SecondZZ => z * z * z / 3.0 * weighted_normal.z,
181        Integrand::ProductXY => 0.5 * x * x * y * weighted_normal.x,
182        Integrand::ProductXZ => 0.5 * x * x * z * weighted_normal.x,
183        Integrand::ProductYZ => 0.5 * y * y * z * weighted_normal.y,
184    }
185}
186
187
188/// Stations per direction in a fixed tensor Gauss block.
189pub(super) const GAUSS_COUNT: usize = GAUSS_X.len();
190
191/// The tensor-block station evaluator is on unless `BREP_MASS_TENSOR_BLOCK=0`
192/// turns it off. It is bit-identical to the per-station path by construction
193/// (`NurbsSurface::deriv1_tensor_each`), so the switch buys nothing but a
194/// paired A/B timing of the two in ONE binary under ONE load — which is the
195/// only honest way to quote a speed-up on a box that runs eight agents.
196pub(super) fn tensor_blocks_on() -> bool {
197    switch!("BREP_MASS_TENSOR_BLOCK", true)
198}
199
200/// Accumulate every `kind` over ONE parameter cell by tensor Gauss–Legendre,
201/// with every station's weight scaled — the trimmed path's full cells carry
202/// their winding and the sweep's orientation there. `scale` multiplies FIRST,
203/// as every call site has always written the product, so a cell read at the
204/// base rule is bit-identical to the one-block rule it replaces.
205///
206/// Identical arithmetic to the per-station loop it replaces (kept as the
207/// tests' reference sweep) — the same stations in the same i-outer/j-inner
208/// order, the same `weight_u * weight_v * half_u * half_v * value` product and
209/// the same running sum per slot — but each of the block's u and v
210/// spans/basis rows is evaluated ONCE rather than once per station it takes
211/// part in, which at the base rule is eight rows each instead of sixty-four
212/// evaluations.
213pub(super) fn integrate_cell_scaled(
214    face: &FaceRecord,
215    cell: [f64; 4],
216    scale: f64,
217    kinds: &[Integrand],
218    totals: &mut [f64],
219    rules: &mut rule::SurfaceRules,
220) -> Result<usize, String> {
221    // A rational carrier's cell is read on the panels and at the orders its
222    // own weights ask for (`rule`); a polynomial one keeps ONE base-order
223    // block over the whole cell.
224    let (u_panels, v_panels) = rules.panels(&face.surface, cell)?;
225    let area_slot = if area_adaptive_on() {
226        kinds.iter().position(|kind| matches!(kind, Integrand::Area))
227    } else {
228        None
229    };
230    let before = area_slot.map(|slot| totals[slot]);
231    let mut stations = 0;
232    for u_panel in &u_panels {
233        for v_panel in &v_panels {
234            integrate_block(face, *u_panel, *v_panel, scale, kinds, totals, rules.station_identity)?;
235            stations += u_panel.order * v_panel.order;
236        }
237    }
238    if let (Some(slot), Some(before)) = (area_slot, before) {
239        // The area integrand |S_u × S_v| is a square root: no fixed-order
240        // rule reads it exactly, polynomial carrier or not, so the cell's
241        // AREA alone is re-read adaptively and every other integrand keeps
242        // the reading above bit for bit.
243        let coarse = (totals[slot] - before) / scale;
244        totals[slot] = before + scale * adaptive_cell_area(face, cell, coarse, rules, AREA_ADAPTIVE_DEPTH, &mut stations)?;
245    }
246    Ok(stations)
247}
248
249/// `BREP_MASS_AREA_ADAPTIVE` (default on) re-reads each cell's AREA by quadrant
250/// refinement until a cell and its four quadrants agree to
251/// [`AREA_ADAPTIVE_TOLERANCE`] of the cell's own area. Default ON since the
252/// 2026-11-05 promotion (owner: accuracy beats speed); `=0` restores one
253/// fixed-rule reading per cell for every integrand.
254///
255/// brep-bench 2026-11-05: one 8-point block per knot cell read the degree-15
256/// Bézier dome's patch (`geometry/high-degree-bezier-dome`) 2.3e-5 small and
257/// the twisted sweep's degree 1×3 blade faces (`loft/twisted-thin-blade`)
258/// 1.7e-5 large, while their volumes were exact to round-off; refined, they
259/// read 1.6e-9 and 1.4e-7 of the closed forms.
260pub(super) fn area_adaptive_on() -> bool {
261    switch!("BREP_MASS_AREA_ADAPTIVE", true)
262}
263
264/// How many times a cell may be quartered while its area is re-read.
265const AREA_ADAPTIVE_DEPTH: usize = 6;
266
267/// Relative agreement between a cell's area and the sum of its quadrants'
268/// that ends the refinement: a few hundred ulps of the cell's own area.
269const AREA_ADAPTIVE_TOLERANCE: f64 = 1e-11;
270
271/// The area over `cell` (unscaled), given the reading `coarse` of the whole
272/// cell: the four quadrants are read on their own panels, and any quadrant
273/// whose own refinement still moves is quartered again, to `depth` levels.
274fn adaptive_cell_area(
275    face: &FaceRecord,
276    cell: [f64; 4],
277    coarse: f64,
278    rules: &mut rule::SurfaceRules,
279    depth: usize,
280    stations: &mut usize,
281) -> Result<f64, String> {
282    if depth == 0 {
283        return Ok(coarse);
284    }
285    let [u_lo, u_hi, v_lo, v_hi] = cell;
286    let (u_mid, v_mid) = (0.5 * (u_lo + u_hi), 0.5 * (v_lo + v_hi));
287    let quadrants = [
288        [u_lo, u_mid, v_lo, v_mid],
289        [u_mid, u_hi, v_lo, v_mid],
290        [u_lo, u_mid, v_mid, v_hi],
291        [u_mid, u_hi, v_mid, v_hi],
292    ];
293    let mut readings = [0.0f64; 4];
294    for (index, quadrant) in quadrants.iter().enumerate() {
295        let (u_panels, v_panels) = rules.panels(&face.surface, *quadrant)?;
296        let mut area = [0.0f64];
297        for u_panel in &u_panels {
298            for v_panel in &v_panels {
299                integrate_block(face, *u_panel, *v_panel, 1.0, &[Integrand::Area], &mut area, rules.station_identity)?;
300                *stations += u_panel.order * v_panel.order;
301            }
302        }
303        readings[index] = area[0];
304    }
305    let fine: f64 = readings.iter().sum();
306    if (fine - coarse).abs() <= AREA_ADAPTIVE_TOLERANCE * fine.abs() {
307        return Ok(fine);
308    }
309    let mut total = 0.0;
310    for (index, quadrant) in quadrants.iter().enumerate() {
311        total += adaptive_cell_area(face, *quadrant, readings[index], rules, depth - 1, stations)?;
312    }
313    Ok(total)
314}
315
316/// One tensor Gauss block: every station of `u_panel × v_panel`, every kind.
317fn integrate_block(
318    face: &FaceRecord,
319    u_panel: rule::Panel,
320    v_panel: rule::Panel,
321    scale: f64,
322    kinds: &[Integrand],
323    totals: &mut [f64],
324    station_identity: Option<[u64; 2]>,
325) -> Result<(), String> {
326    let half_u = (u_panel.hi - u_panel.lo) * 0.5;
327    let middle_u = (u_panel.hi + u_panel.lo) * 0.5;
328    let half_v = (v_panel.hi - v_panel.lo) * 0.5;
329    let middle_v = (v_panel.hi + v_panel.lo) * 0.5;
330    let (u_x, u_w) = u_panel.nodes();
331    let (v_x, v_w) = v_panel.nodes();
332    let sign = if face.same_sense { 1.0 } else { -1.0 };
333    // The base rule, written against the fixed-size tables: its trip count is
334    // a constant the compiler unrolls, and reading the nodes through a slice
335    // of run-time length instead cost 0.28 µs a station on a 900 000-station
336    // import (measured 2026-09-15). Every polynomial carrier is on this path,
337    // and it is the arithmetic the one-block rule always did.
338    let base_rule = u_x.len() == GAUSS_COUNT && v_x.len() == GAUSS_COUNT;
339    if base_rule && tensor_blocks_on() && face.surface.deriv1_tensor_supported() {
340        let mut u_stations = [0.0f64; GAUSS_COUNT];
341        let mut v_stations = [0.0f64; GAUSS_COUNT];
342        for index in 0..GAUSS_COUNT {
343            u_stations[index] = middle_u + half_u * GAUSS_X[index];
344            v_stations[index] = middle_v + half_v * GAUSS_X[index];
345        }
346        return super::cell_memo::each(&face.surface, station_identity, &u_stations, &v_stations, |i, j, point, su, sv| {
347                let weighted_normal = su.cross(sv).scale(sign);
348                for (slot, kind) in kinds.iter().enumerate() {
349                    totals[slot] += scale
350                        * GAUSS_W[i]
351                        * GAUSS_W[j]
352                        * half_u
353                        * half_v
354                        * integrand_value(*kind, point, weighted_normal);
355                }
356                Ok(())
357            });
358    }
359    if base_rule && !face.surface.deriv1_tensor_supported() {
360        for i in 0..GAUSS_COUNT {
361            for j in 0..GAUSS_COUNT {
362                let (point, su, sv) = face
363                    .surface
364                    .deriv1(middle_u + half_u * GAUSS_X[i], middle_v + half_v * GAUSS_X[j])?;
365                let weighted_normal = su.cross(sv).scale(sign);
366                for (slot, kind) in kinds.iter().enumerate() {
367                    totals[slot] += scale
368                        * GAUSS_W[i]
369                        * GAUSS_W[j]
370                        * half_u
371                        * half_v
372                        * integrand_value(*kind, point, weighted_normal);
373                }
374            }
375        }
376        return Ok(());
377    }
378    if tensor_blocks_on() && face.surface.deriv1_tensor_supported() {
379        let mut u_stations = [0.0f64; rule::MAX_ORDER];
380        let mut v_stations = [0.0f64; rule::MAX_ORDER];
381        for index in 0..u_x.len() {
382            u_stations[index] = middle_u + half_u * u_x[index];
383        }
384        for index in 0..v_x.len() {
385            v_stations[index] = middle_v + half_v * v_x[index];
386        }
387        return super::cell_memo::each(
388            &face.surface, station_identity,
389            &u_stations[..u_x.len()],
390            &v_stations[..v_x.len()],
391            |i, j, point, su, sv| {
392                let weighted_normal = su.cross(sv).scale(sign);
393                for (slot, kind) in kinds.iter().enumerate() {
394                    totals[slot] += scale
395                        * u_w[i]
396                        * v_w[j]
397                        * half_u
398                        * half_v
399                        * integrand_value(*kind, point, weighted_normal);
400                }
401                Ok(())
402            },
403        );
404    }
405    for i in 0..u_x.len() {
406        for j in 0..v_x.len() {
407            let (point, su, sv) = face
408                .surface
409                .deriv1(middle_u + half_u * u_x[i], middle_v + half_v * v_x[j])?;
410            let weighted_normal = su.cross(sv).scale(sign);
411            for (slot, kind) in kinds.iter().enumerate() {
412                totals[slot] += scale
413                    * u_w[i]
414                    * v_w[j]
415                    * half_u
416                    * half_v
417                    * integrand_value(*kind, point, weighted_normal);
418            }
419        }
420    }
421    Ok(())
422}
423
424/// Every `kind` over the whole (untrimmed) knot-span grid in ONE sweep.
425/// `solid_mass_properties` asks an untrimmed face for its area and its volume
426/// flux; two single-kind sweeps evaluated every station twice for a second
427/// integrand that costs a multiply.
428pub(super) fn integrate_untrimmed_multi(
429    face: &FaceRecord,
430    kinds: &[Integrand],
431) -> Result<Vec<f64>, String> {
432    let (u_breaks, v_breaks) = surface_breaks(&face.surface)?;
433    let mut totals = vec![0.0f64; kinds.len()];
434    let mut rules = rule::SurfaceRules::new(&face.surface)?;
435    for upair in u_breaks.windows(2) {
436        for vpair in v_breaks.windows(2) {
437            integrate_cell_scaled(
438                face,
439                [upair[0], upair[1], vpair[0], vpair[1]],
440                1.0,
441                kinds,
442                &mut totals,
443                &mut rules,
444            )?;
445        }
446    }
447    Ok(totals)
448}
449
450/// Every `kind` over the knot-span grid CLIPPED to `rectangle`
451/// (`[u_lo, u_hi, v_lo, v_hi]`, inside the domain): the untrimmed sweep's
452/// own rules on each cell's intersection with the rectangle, so a face whose
453/// single loop is an iso-rectangle (`polygons::iso_rectangle_trim`) is read
454/// without the trimmed route's chord polygons, cell quadtree and triangle
455/// cubature. Lane O, 2026-10-28: the filleted box's outward offset carried
456/// its quarter-cylinder image as such a face (the carrier extended past the
457/// walls, the trim v in [0.069, 0.931]) and the trimmed route spent 6.5 ms
458/// on 496 full cells and 31 744 stations for a one-span surface; this sweep
459/// reads it in a few stations per span.
460pub(super) fn integrate_iso_rectangle_multi(
461    face: &FaceRecord,
462    rectangle: [f64; 4],
463    kinds: &[Integrand],
464) -> Result<Vec<f64>, String> {
465    let (u_breaks, v_breaks) = surface_breaks(&face.surface)?;
466    let mut totals = vec![0.0f64; kinds.len()];
467    let mut rules = rule::SurfaceRules::new(&face.surface)?;
468    for upair in u_breaks.windows(2) {
469        let (u_lo, u_hi) = (upair[0].max(rectangle[0]), upair[1].min(rectangle[1]));
470        if u_hi <= u_lo {
471            continue;
472        }
473        for vpair in v_breaks.windows(2) {
474            let (v_lo, v_hi) = (vpair[0].max(rectangle[2]), vpair[1].min(rectangle[3]));
475            if v_hi <= v_lo {
476                continue;
477            }
478            integrate_cell_scaled(face, [u_lo, u_hi, v_lo, v_hi], 1.0, kinds, &mut totals, &mut rules)?;
479        }
480    }
481    Ok(totals)
482}
483
484pub(super) fn integrate_untrimmed(face: &FaceRecord, kind: Integrand) -> Result<f64, String> {
485    Ok(integrate_untrimmed_multi(face, &[kind])?[0])
486}
487
488/// A bi-periodic band face (surface closed in both u and v, exactly two loops
489/// each a full-wrap constant-cross-level rim) whose trim is NOT expressed with a
490/// seam ruling — the fillet-torus bands OCC/STEP emit as two rim circles. Such a
491/// face integrates to ZERO on the trimmed path (both loops are degenerate iso
492/// lines in parameter space), so it is handled analytically over the seam-cut
493/// rectangle instead. See [`biperiodic_band_range`].
494#[derive(Clone, Copy)]
495pub(super) struct BiBand {
496    /// Which parameter is the periodic (full-wrap) one.
497    pub(super) p_is_u: bool,
498    /// The two rim cross-levels, ascending (q_lo <= q_hi) in the cross param.
499    pub(super) q_lo: f64,
500    pub(super) q_hi: f64,
501    /// True when the material band is the CROSS-SEAM COMPLEMENT of [q_lo, q_hi]
502    /// rather than the between-rims strip (mirrors the tessellator's
503    /// `close_periodic_trim_dir` orientation test).
504    pub(super) complement: bool,
505    /// The larger of the two rims' cross-parameter extents, as a fraction of
506    /// the cross domain. Zero (to double noise) for the rim circles OCC/STEP
507    /// emit as exact iso lines; a few 1e-3 for a rim PROJECTED from a vendor
508    /// 3D curve that sits off the carrier, whose pcurve then wanders in the
509    /// cross parameter. Decides between the seam-cut rectangle and the
510    /// rim-following strip — see [`biperiodic_band_integral`].
511    pub(super) rim_wobble: f64,
512}
513
514/// Above this fraction of the cross domain a rim is not an iso line, and the
515/// band between the rims is integrated along the rims' own pcurves instead
516/// of over the rectangle between their mean levels.
517///
518/// The bar is double noise, not a geometric tolerance: a rim that IS an iso
519/// line measures ~1e-15 here, and the two paths agree on it to quadrature
520/// precision, so nothing that reads exactly today moves. Set at 1e-9 rather
521/// than at the noise floor so a level rim of a large-domain surface is never
522/// pushed onto the pcurve walk by rounding alone. `importTestWorking`
523/// SOLID_03 face 90: a torus fillet strip whose upper rim was projected from
524/// an edge 2.2e-3 mm off the torus wanders over v ∈ [0.746903, 0.750195] —
525/// 3.3e-3 of the period — and the rectangle at the mean level 0.748549 read
526/// the strip 9.67e-3 mm² (0.09%) small and its origin flux 0.388 low.
527pub(super) const BAND_RIM_LEVEL_TOLERANCE_REL: f64 = 1e-9;
528
529/// Detect the bi-periodic band configuration of `face` and choose which of the
530/// two regions its rims bound is material. The choice replicates the watertight
531/// tessellator (`watertight_tessellation::close_periodic_trim_dir`): the
532/// material is to the LEFT of the boundary traversal, so the between-rims strip
533/// is kept unless the cross direction wraps AND the rim senses conclusively name
534/// the complement. Returns None for anything that is not a clean two-rim band.
535pub(super) fn biperiodic_band_range(face: &FaceRecord) -> Result<Option<BiBand>, String> {
536    let surface = &face.surface;
537    let (closed_u, closed_v) = surface.closed_directions()?;
538    if !(closed_u && closed_v) {
539        return Ok(None);
540    }
541    let [u0, u1] = surface.domain_u()?;
542    let [v0, v1] = surface.domain_v()?;
543    let u_span = (u1 - u0).abs().max(1e-30);
544    let v_span = (v1 - v0).abs().max(1e-30);
545    // Sample each loop's pcurves (in coedge/traversal order) into (u,v) points.
546    // A DEGENERATE loop — a pole/apex vertex whose whole trace collapses to one
547    // parameter point (a fat fillet torus touches its outer equator at a single
548    // seam point: ABC 00000039 faces 68/70) — is not a band boundary; drop it
549    // and keep the two real rims. Anything else leaves the band ambiguous.
550    let mut loop_points: Vec<Vec<[f64; 2]>> = Vec::with_capacity(2);
551    for loop_record in &face.loops {
552        let mut points = Vec::new();
553        for coedge in &loop_record.coedges {
554            let [d0, d1] = coedge.pcurve.domain()?;
555            // 32 per coedge: enough to see a projected rim wander, which a
556            // 12-sample read of a smooth pcurve could straddle.
557            let samples = 32;
558            for k in 0..=samples {
559                let t = d0 + (d1 - d0) * k as f64 / samples as f64;
560                let p = coedge.pcurve.evaluate(t)?;
561                points.push([p.x, p.y]);
562            }
563        }
564        if points.len() < 2 {
565            return Ok(None);
566        }
567        let (mut umin, mut umax, mut vmin, mut vmax) = (
568            f64::INFINITY,
569            f64::NEG_INFINITY,
570            f64::INFINITY,
571            f64::NEG_INFINITY,
572        );
573        for pt in &points {
574            umin = umin.min(pt[0]);
575            umax = umax.max(pt[0]);
576            vmin = vmin.min(pt[1]);
577            vmax = vmax.max(pt[1]);
578        }
579        if (umax - umin) <= 1e-3 * u_span && (vmax - vmin) <= 1e-3 * v_span {
580            continue; // degenerate pole/apex loop
581        }
582        loop_points.push(points);
583    }
584    if loop_points.len() != 2 {
585        return Ok(None);
586    }
587    for p_is_u in [true, false] {
588        let (period, _p0, _p1) = if p_is_u {
589            (u1 - u0, u0, u1)
590        } else {
591            (v1 - v0, v0, v1)
592        };
593        let (q_dom_lo, q_dom_hi) = if p_is_u { (v0, v1) } else { (u0, u1) };
594        let q_extent = (q_dom_hi - q_dom_lo).abs().max(1e-30);
595        if !(period > 0.0) {
596            continue;
597        }
598        let coord = |pt: &[f64; 2]| -> (f64, f64) {
599            if p_is_u {
600                (pt[0], pt[1])
601            } else {
602                (pt[1], pt[0])
603            }
604        };
605        // (level, winding-direction) for each rim; None if any loop is not a
606        // clean full-wrap constant-cross-level rim in this p direction.
607        let mut rings: Vec<(f64, i32)> = Vec::with_capacity(2);
608        let mut clean = true;
609        let mut rim_wobble = 0.0f64;
610        for points in &loop_points {
611            let (mut pmin, mut pmax, mut qmin, mut qmax) = (
612                f64::INFINITY,
613                f64::NEG_INFINITY,
614                f64::INFINITY,
615                f64::NEG_INFINITY,
616            );
617            for pt in points {
618                let (p, q) = coord(pt);
619                pmin = pmin.min(p);
620                pmax = pmax.max(p);
621                qmin = qmin.min(q);
622                qmax = qmax.max(q);
623            }
624            if (pmax - pmin) < 0.6 * period || (qmax - qmin) > 0.05 * q_extent {
625                clean = false;
626                break;
627            }
628            rim_wobble = rim_wobble.max((qmax - qmin) / q_extent);
629            let mut net = 0.0;
630            for pair in points.windows(2) {
631                let mut delta = coord(&pair[1]).0 - coord(&pair[0]).0;
632                if delta > 0.5 * period {
633                    delta -= period;
634                } else if delta < -0.5 * period {
635                    delta += period;
636                }
637                net += delta;
638            }
639            let direction = if net > 0.25 * period {
640                1
641            } else if net < -0.25 * period {
642                -1
643            } else {
644                0
645            };
646            rings.push((0.5 * (qmin + qmax), direction));
647        }
648        if !clean || rings.len() != 2 {
649            continue;
650        }
651        rings.sort_by(|a, b| a.0.total_cmp(&b.0));
652        let (q_lo, lower_dir) = rings[0];
653        let (q_hi, upper_dir) = rings[1];
654        let inconclusive = lower_dir == 0 || upper_dir == 0 || lower_dir == upper_dir;
655        let between_rims_is_ccw_uv = if p_is_u { lower_dir > 0 } else { lower_dir < 0 };
656        let complement = !inconclusive && between_rims_is_ccw_uv != face.same_sense;
657        return Ok(Some(BiBand {
658            p_is_u,
659            q_lo,
660            q_hi,
661            complement,
662            rim_wobble,
663        }));
664    }
665    Ok(None)
666}
667
668/// Gauss–Legendre integral of `kind` over the axis-aligned parameter rectangle
669/// [u_lo,u_hi]×[v_lo,v_hi], subdividing at knot spans clipped to the rectangle.
670pub(super) fn integrate_rectangle(
671    face: &FaceRecord,
672    u_lo: f64,
673    u_hi: f64,
674    v_lo: f64,
675    v_hi: f64,
676    kind: Integrand,
677) -> Result<f64, String> {
678    let (u_full, v_full) = surface_breaks(&face.surface)?;
679    let clamp = |breaks: &[f64], lo: f64, hi: f64| -> Vec<f64> {
680        let eps = 1e-9 * (hi - lo).abs().max(1e-30);
681        let mut out = vec![lo];
682        for &b in breaks {
683            if b > lo + eps && b < hi - eps {
684                out.push(b);
685            }
686        }
687        out.push(hi);
688        out
689    };
690    let u_breaks = clamp(&u_full, u_lo, u_hi);
691    let v_breaks = clamp(&v_full, v_lo, v_hi);
692    let mut totals = [0.0f64];
693    let mut rules = rule::SurfaceRules::new(&face.surface)?;
694    for upair in u_breaks.windows(2) {
695        for vpair in v_breaks.windows(2) {
696            integrate_cell_scaled(
697                face,
698                [upair[0], upair[1], vpair[0], vpair[1]],
699                1.0,
700                &[kind],
701                &mut totals,
702                &mut rules,
703            )?;
704        }
705    }
706    Ok(totals[0])
707}
708
709/// Integrate each of `kinds` over a bi-periodic band face, or return None when
710/// `face` is not such a band. The between-rims strip integrates over the full
711/// periodic span × the cross-level strip; the complement is the full-domain
712/// integral MINUS that strip (the two tile the closed cross period, so no
713/// domain-extended evaluation is needed).
714///
715/// Two readings of the strip. Rims that ARE iso lines (every rim circle a
716/// kernel-built or exactly exported torus carries) integrate over the
717/// seam-cut rectangle between their levels. A rim that wanders in the cross
718/// parameter — a pcurve PROJECTED from a vendor edge that sits off the
719/// carrier — is not at any one level, and the rectangle at its mean level
720/// mis-reads the strip by the wander times the rim length; such a band is
721/// integrated along the rims' own pcurves by the winding boundary integral
722/// (`∮ −G du`, [`super::winding::winding_loops_integral`]), which reads the
723/// trim exactly as given. The material choice (`complement`) is the same in
724/// both readings; only the strip's quadrature differs.
725pub(super) fn biperiodic_band_integral(
726    face: &FaceRecord,
727    kinds: &[Integrand],
728) -> Result<Option<Vec<f64>>, String> {
729    let Some(band) = biperiodic_band_range(face)? else {
730        return Ok(None);
731    };
732    let [u0, u1] = face.surface.domain_u()?;
733    let [v0, v1] = face.surface.domain_v()?;
734    let (u_lo, u_hi, v_lo, v_hi) = if band.p_is_u {
735        (u0, u1, band.q_lo, band.q_hi)
736    } else {
737        (band.q_lo, band.q_hi, v0, v1)
738    };
739    let strips: Vec<f64> = if band.rim_wobble <= BAND_RIM_LEVEL_TOLERANCE_REL {
740        kinds
741            .iter()
742            .map(|&kind| integrate_rectangle(face, u_lo, u_hi, v_lo, v_hi, kind))
743            .collect::<Result<_, _>>()?
744    } else {
745        super::winding::winding_loops_integral(face, kinds, band.p_is_u, 1)?
746    };
747    let mut out = Vec::with_capacity(kinds.len());
748    for (&kind, strip) in kinds.iter().zip(strips) {
749        let value = if band.complement {
750            integrate_untrimmed(face, kind)? - strip
751        } else {
752            strip
753        };
754        out.push(value);
755    }
756    Ok(Some(out))
757}