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