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 mut stations = 0;
226    for u_panel in &u_panels {
227        for v_panel in &v_panels {
228            integrate_block(face, *u_panel, *v_panel, scale, kinds, totals)?;
229            stations += u_panel.order * v_panel.order;
230        }
231    }
232    Ok(stations)
233}
234
235/// One tensor Gauss block: every station of `u_panel × v_panel`, every kind.
236fn integrate_block(
237    face: &FaceRecord,
238    u_panel: rule::Panel,
239    v_panel: rule::Panel,
240    scale: f64,
241    kinds: &[Integrand],
242    totals: &mut [f64],
243) -> Result<(), String> {
244    let half_u = (u_panel.hi - u_panel.lo) * 0.5;
245    let middle_u = (u_panel.hi + u_panel.lo) * 0.5;
246    let half_v = (v_panel.hi - v_panel.lo) * 0.5;
247    let middle_v = (v_panel.hi + v_panel.lo) * 0.5;
248    let (u_x, u_w) = u_panel.nodes();
249    let (v_x, v_w) = v_panel.nodes();
250    let sign = if face.same_sense { 1.0 } else { -1.0 };
251    // The base rule, written against the fixed-size tables: its trip count is
252    // a constant the compiler unrolls, and reading the nodes through a slice
253    // of run-time length instead cost 0.28 µs a station on a 900 000-station
254    // import (measured 2026-09-15). Every polynomial carrier is on this path,
255    // and it is the arithmetic the one-block rule always did.
256    let base_rule = u_x.len() == GAUSS_COUNT && v_x.len() == GAUSS_COUNT;
257    if base_rule && tensor_blocks_on() && face.surface.deriv1_tensor_supported() {
258        let mut u_stations = [0.0f64; GAUSS_COUNT];
259        let mut v_stations = [0.0f64; GAUSS_COUNT];
260        for index in 0..GAUSS_COUNT {
261            u_stations[index] = middle_u + half_u * GAUSS_X[index];
262            v_stations[index] = middle_v + half_v * GAUSS_X[index];
263        }
264        return face
265            .surface
266            .deriv1_tensor_each(&u_stations, &v_stations, |i, j, point, su, sv| {
267                let weighted_normal = su.cross(sv).scale(sign);
268                for (slot, kind) in kinds.iter().enumerate() {
269                    totals[slot] += scale
270                        * GAUSS_W[i]
271                        * GAUSS_W[j]
272                        * half_u
273                        * half_v
274                        * integrand_value(*kind, point, weighted_normal);
275                }
276                Ok(())
277            });
278    }
279    if base_rule && !face.surface.deriv1_tensor_supported() {
280        for i in 0..GAUSS_COUNT {
281            for j in 0..GAUSS_COUNT {
282                let (point, su, sv) = face
283                    .surface
284                    .deriv1(middle_u + half_u * GAUSS_X[i], middle_v + half_v * GAUSS_X[j])?;
285                let weighted_normal = su.cross(sv).scale(sign);
286                for (slot, kind) in kinds.iter().enumerate() {
287                    totals[slot] += scale
288                        * GAUSS_W[i]
289                        * GAUSS_W[j]
290                        * half_u
291                        * half_v
292                        * integrand_value(*kind, point, weighted_normal);
293                }
294            }
295        }
296        return Ok(());
297    }
298    if tensor_blocks_on() && face.surface.deriv1_tensor_supported() {
299        let mut u_stations = [0.0f64; rule::MAX_ORDER];
300        let mut v_stations = [0.0f64; rule::MAX_ORDER];
301        for index in 0..u_x.len() {
302            u_stations[index] = middle_u + half_u * u_x[index];
303        }
304        for index in 0..v_x.len() {
305            v_stations[index] = middle_v + half_v * v_x[index];
306        }
307        return face.surface.deriv1_tensor_each(
308            &u_stations[..u_x.len()],
309            &v_stations[..v_x.len()],
310            |i, j, point, su, sv| {
311                let weighted_normal = su.cross(sv).scale(sign);
312                for (slot, kind) in kinds.iter().enumerate() {
313                    totals[slot] += scale
314                        * u_w[i]
315                        * v_w[j]
316                        * half_u
317                        * half_v
318                        * integrand_value(*kind, point, weighted_normal);
319                }
320                Ok(())
321            },
322        );
323    }
324    for i in 0..u_x.len() {
325        for j in 0..v_x.len() {
326            let (point, su, sv) = face
327                .surface
328                .deriv1(middle_u + half_u * u_x[i], middle_v + half_v * v_x[j])?;
329            let weighted_normal = su.cross(sv).scale(sign);
330            for (slot, kind) in kinds.iter().enumerate() {
331                totals[slot] += scale
332                    * u_w[i]
333                    * v_w[j]
334                    * half_u
335                    * half_v
336                    * integrand_value(*kind, point, weighted_normal);
337            }
338        }
339    }
340    Ok(())
341}
342
343/// Every `kind` over the whole (untrimmed) knot-span grid in ONE sweep.
344/// `solid_mass_properties` asks an untrimmed face for its area and its volume
345/// flux; two single-kind sweeps evaluated every station twice for a second
346/// integrand that costs a multiply.
347pub(super) fn integrate_untrimmed_multi(
348    face: &FaceRecord,
349    kinds: &[Integrand],
350) -> Result<Vec<f64>, String> {
351    let (u_breaks, v_breaks) = surface_breaks(&face.surface)?;
352    let mut totals = vec![0.0f64; kinds.len()];
353    let mut rules = rule::SurfaceRules::new(&face.surface)?;
354    for upair in u_breaks.windows(2) {
355        for vpair in v_breaks.windows(2) {
356            integrate_cell_scaled(
357                face,
358                [upair[0], upair[1], vpair[0], vpair[1]],
359                1.0,
360                kinds,
361                &mut totals,
362                &mut rules,
363            )?;
364        }
365    }
366    Ok(totals)
367}
368
369/// Every `kind` over the knot-span grid CLIPPED to `rectangle`
370/// (`[u_lo, u_hi, v_lo, v_hi]`, inside the domain): the untrimmed sweep's
371/// own rules on each cell's intersection with the rectangle, so a face whose
372/// single loop is an iso-rectangle (`polygons::iso_rectangle_trim`) is read
373/// without the trimmed route's chord polygons, cell quadtree and triangle
374/// cubature. Lane O, 2026-10-28: the filleted box's outward offset carried
375/// its quarter-cylinder image as such a face (the carrier extended past the
376/// walls, the trim v in [0.069, 0.931]) and the trimmed route spent 6.5 ms
377/// on 496 full cells and 31 744 stations for a one-span surface; this sweep
378/// reads it in a few stations per span.
379pub(super) fn integrate_iso_rectangle_multi(
380    face: &FaceRecord,
381    rectangle: [f64; 4],
382    kinds: &[Integrand],
383) -> Result<Vec<f64>, String> {
384    let (u_breaks, v_breaks) = surface_breaks(&face.surface)?;
385    let mut totals = vec![0.0f64; kinds.len()];
386    let mut rules = rule::SurfaceRules::new(&face.surface)?;
387    for upair in u_breaks.windows(2) {
388        let (u_lo, u_hi) = (upair[0].max(rectangle[0]), upair[1].min(rectangle[1]));
389        if u_hi <= u_lo {
390            continue;
391        }
392        for vpair in v_breaks.windows(2) {
393            let (v_lo, v_hi) = (vpair[0].max(rectangle[2]), vpair[1].min(rectangle[3]));
394            if v_hi <= v_lo {
395                continue;
396            }
397            integrate_cell_scaled(face, [u_lo, u_hi, v_lo, v_hi], 1.0, kinds, &mut totals, &mut rules)?;
398        }
399    }
400    Ok(totals)
401}
402
403pub(super) fn integrate_untrimmed(face: &FaceRecord, kind: Integrand) -> Result<f64, String> {
404    Ok(integrate_untrimmed_multi(face, &[kind])?[0])
405}
406
407/// A bi-periodic band face (surface closed in both u and v, exactly two loops
408/// each a full-wrap constant-cross-level rim) whose trim is NOT expressed with a
409/// seam ruling — the fillet-torus bands OCC/STEP emit as two rim circles. Such a
410/// face integrates to ZERO on the trimmed path (both loops are degenerate iso
411/// lines in parameter space), so it is handled analytically over the seam-cut
412/// rectangle instead. See [`biperiodic_band_range`].
413#[derive(Clone, Copy)]
414pub(super) struct BiBand {
415    /// Which parameter is the periodic (full-wrap) one.
416    pub(super) p_is_u: bool,
417    /// The two rim cross-levels, ascending (q_lo <= q_hi) in the cross param.
418    pub(super) q_lo: f64,
419    pub(super) q_hi: f64,
420    /// True when the material band is the CROSS-SEAM COMPLEMENT of [q_lo, q_hi]
421    /// rather than the between-rims strip (mirrors the tessellator's
422    /// `close_periodic_trim_dir` orientation test).
423    pub(super) complement: bool,
424    /// The larger of the two rims' cross-parameter extents, as a fraction of
425    /// the cross domain. Zero (to double noise) for the rim circles OCC/STEP
426    /// emit as exact iso lines; a few 1e-3 for a rim PROJECTED from a vendor
427    /// 3D curve that sits off the carrier, whose pcurve then wanders in the
428    /// cross parameter. Decides between the seam-cut rectangle and the
429    /// rim-following strip — see [`biperiodic_band_integral`].
430    pub(super) rim_wobble: f64,
431}
432
433/// Above this fraction of the cross domain a rim is not an iso line, and the
434/// band between the rims is integrated along the rims' own pcurves instead
435/// of over the rectangle between their mean levels.
436///
437/// The bar is double noise, not a geometric tolerance: a rim that IS an iso
438/// line measures ~1e-15 here, and the two paths agree on it to quadrature
439/// precision, so nothing that reads exactly today moves. Set at 1e-9 rather
440/// than at the noise floor so a level rim of a large-domain surface is never
441/// pushed onto the pcurve walk by rounding alone. `importTestWorking`
442/// SOLID_03 face 90: a torus fillet strip whose upper rim was projected from
443/// an edge 2.2e-3 mm off the torus wanders over v ∈ [0.746903, 0.750195] —
444/// 3.3e-3 of the period — and the rectangle at the mean level 0.748549 read
445/// the strip 9.67e-3 mm² (0.09%) small and its origin flux 0.388 low.
446pub(super) const BAND_RIM_LEVEL_TOLERANCE_REL: f64 = 1e-9;
447
448/// Detect the bi-periodic band configuration of `face` and choose which of the
449/// two regions its rims bound is material. The choice replicates the watertight
450/// tessellator (`watertight_tessellation::close_periodic_trim_dir`): the
451/// material is to the LEFT of the boundary traversal, so the between-rims strip
452/// is kept unless the cross direction wraps AND the rim senses conclusively name
453/// the complement. Returns None for anything that is not a clean two-rim band.
454pub(super) fn biperiodic_band_range(face: &FaceRecord) -> Result<Option<BiBand>, String> {
455    let surface = &face.surface;
456    let (closed_u, closed_v) = surface.closed_directions()?;
457    if !(closed_u && closed_v) {
458        return Ok(None);
459    }
460    let [u0, u1] = surface.domain_u()?;
461    let [v0, v1] = surface.domain_v()?;
462    let u_span = (u1 - u0).abs().max(1e-30);
463    let v_span = (v1 - v0).abs().max(1e-30);
464    // Sample each loop's pcurves (in coedge/traversal order) into (u,v) points.
465    // A DEGENERATE loop — a pole/apex vertex whose whole trace collapses to one
466    // parameter point (a fat fillet torus touches its outer equator at a single
467    // seam point: ABC 00000039 faces 68/70) — is not a band boundary; drop it
468    // and keep the two real rims. Anything else leaves the band ambiguous.
469    let mut loop_points: Vec<Vec<[f64; 2]>> = Vec::with_capacity(2);
470    for loop_record in &face.loops {
471        let mut points = Vec::new();
472        for coedge in &loop_record.coedges {
473            let [d0, d1] = coedge.pcurve.domain()?;
474            // 32 per coedge: enough to see a projected rim wander, which a
475            // 12-sample read of a smooth pcurve could straddle.
476            let samples = 32;
477            for k in 0..=samples {
478                let t = d0 + (d1 - d0) * k as f64 / samples as f64;
479                let p = coedge.pcurve.evaluate(t)?;
480                points.push([p.x, p.y]);
481            }
482        }
483        if points.len() < 2 {
484            return Ok(None);
485        }
486        let (mut umin, mut umax, mut vmin, mut vmax) = (
487            f64::INFINITY,
488            f64::NEG_INFINITY,
489            f64::INFINITY,
490            f64::NEG_INFINITY,
491        );
492        for pt in &points {
493            umin = umin.min(pt[0]);
494            umax = umax.max(pt[0]);
495            vmin = vmin.min(pt[1]);
496            vmax = vmax.max(pt[1]);
497        }
498        if (umax - umin) <= 1e-3 * u_span && (vmax - vmin) <= 1e-3 * v_span {
499            continue; // degenerate pole/apex loop
500        }
501        loop_points.push(points);
502    }
503    if loop_points.len() != 2 {
504        return Ok(None);
505    }
506    for p_is_u in [true, false] {
507        let (period, _p0, _p1) = if p_is_u {
508            (u1 - u0, u0, u1)
509        } else {
510            (v1 - v0, v0, v1)
511        };
512        let (q_dom_lo, q_dom_hi) = if p_is_u { (v0, v1) } else { (u0, u1) };
513        let q_extent = (q_dom_hi - q_dom_lo).abs().max(1e-30);
514        if !(period > 0.0) {
515            continue;
516        }
517        let coord = |pt: &[f64; 2]| -> (f64, f64) {
518            if p_is_u {
519                (pt[0], pt[1])
520            } else {
521                (pt[1], pt[0])
522            }
523        };
524        // (level, winding-direction) for each rim; None if any loop is not a
525        // clean full-wrap constant-cross-level rim in this p direction.
526        let mut rings: Vec<(f64, i32)> = Vec::with_capacity(2);
527        let mut clean = true;
528        let mut rim_wobble = 0.0f64;
529        for points in &loop_points {
530            let (mut pmin, mut pmax, mut qmin, mut qmax) = (
531                f64::INFINITY,
532                f64::NEG_INFINITY,
533                f64::INFINITY,
534                f64::NEG_INFINITY,
535            );
536            for pt in points {
537                let (p, q) = coord(pt);
538                pmin = pmin.min(p);
539                pmax = pmax.max(p);
540                qmin = qmin.min(q);
541                qmax = qmax.max(q);
542            }
543            if (pmax - pmin) < 0.6 * period || (qmax - qmin) > 0.05 * q_extent {
544                clean = false;
545                break;
546            }
547            rim_wobble = rim_wobble.max((qmax - qmin) / q_extent);
548            let mut net = 0.0;
549            for pair in points.windows(2) {
550                let mut delta = coord(&pair[1]).0 - coord(&pair[0]).0;
551                if delta > 0.5 * period {
552                    delta -= period;
553                } else if delta < -0.5 * period {
554                    delta += period;
555                }
556                net += delta;
557            }
558            let direction = if net > 0.25 * period {
559                1
560            } else if net < -0.25 * period {
561                -1
562            } else {
563                0
564            };
565            rings.push((0.5 * (qmin + qmax), direction));
566        }
567        if !clean || rings.len() != 2 {
568            continue;
569        }
570        rings.sort_by(|a, b| a.0.total_cmp(&b.0));
571        let (q_lo, lower_dir) = rings[0];
572        let (q_hi, upper_dir) = rings[1];
573        let inconclusive = lower_dir == 0 || upper_dir == 0 || lower_dir == upper_dir;
574        let between_rims_is_ccw_uv = if p_is_u { lower_dir > 0 } else { lower_dir < 0 };
575        let complement = !inconclusive && between_rims_is_ccw_uv != face.same_sense;
576        return Ok(Some(BiBand {
577            p_is_u,
578            q_lo,
579            q_hi,
580            complement,
581            rim_wobble,
582        }));
583    }
584    Ok(None)
585}
586
587/// Gauss–Legendre integral of `kind` over the axis-aligned parameter rectangle
588/// [u_lo,u_hi]×[v_lo,v_hi], subdividing at knot spans clipped to the rectangle.
589pub(super) fn integrate_rectangle(
590    face: &FaceRecord,
591    u_lo: f64,
592    u_hi: f64,
593    v_lo: f64,
594    v_hi: f64,
595    kind: Integrand,
596) -> Result<f64, String> {
597    let (u_full, v_full) = surface_breaks(&face.surface)?;
598    let clamp = |breaks: &[f64], lo: f64, hi: f64| -> Vec<f64> {
599        let eps = 1e-9 * (hi - lo).abs().max(1e-30);
600        let mut out = vec![lo];
601        for &b in breaks {
602            if b > lo + eps && b < hi - eps {
603                out.push(b);
604            }
605        }
606        out.push(hi);
607        out
608    };
609    let u_breaks = clamp(&u_full, u_lo, u_hi);
610    let v_breaks = clamp(&v_full, v_lo, v_hi);
611    let mut totals = [0.0f64];
612    let mut rules = rule::SurfaceRules::new(&face.surface)?;
613    for upair in u_breaks.windows(2) {
614        for vpair in v_breaks.windows(2) {
615            integrate_cell_scaled(
616                face,
617                [upair[0], upair[1], vpair[0], vpair[1]],
618                1.0,
619                &[kind],
620                &mut totals,
621                &mut rules,
622            )?;
623        }
624    }
625    Ok(totals[0])
626}
627
628/// Integrate each of `kinds` over a bi-periodic band face, or return None when
629/// `face` is not such a band. The between-rims strip integrates over the full
630/// periodic span × the cross-level strip; the complement is the full-domain
631/// integral MINUS that strip (the two tile the closed cross period, so no
632/// domain-extended evaluation is needed).
633///
634/// Two readings of the strip. Rims that ARE iso lines (every rim circle a
635/// kernel-built or exactly exported torus carries) integrate over the
636/// seam-cut rectangle between their levels. A rim that wanders in the cross
637/// parameter — a pcurve PROJECTED from a vendor edge that sits off the
638/// carrier — is not at any one level, and the rectangle at its mean level
639/// mis-reads the strip by the wander times the rim length; such a band is
640/// integrated along the rims' own pcurves by the winding boundary integral
641/// (`∮ −G du`, [`super::winding::winding_loops_integral`]), which reads the
642/// trim exactly as given. The material choice (`complement`) is the same in
643/// both readings; only the strip's quadrature differs.
644pub(super) fn biperiodic_band_integral(
645    face: &FaceRecord,
646    kinds: &[Integrand],
647) -> Result<Option<Vec<f64>>, String> {
648    let Some(band) = biperiodic_band_range(face)? else {
649        return Ok(None);
650    };
651    let [u0, u1] = face.surface.domain_u()?;
652    let [v0, v1] = face.surface.domain_v()?;
653    let (u_lo, u_hi, v_lo, v_hi) = if band.p_is_u {
654        (u0, u1, band.q_lo, band.q_hi)
655    } else {
656        (band.q_lo, band.q_hi, v0, v1)
657    };
658    let strips: Vec<f64> = if band.rim_wobble <= BAND_RIM_LEVEL_TOLERANCE_REL {
659        kinds
660            .iter()
661            .map(|&kind| integrate_rectangle(face, u_lo, u_hi, v_lo, v_hi, kind))
662            .collect::<Result<_, _>>()?
663    } else {
664        super::winding::winding_loops_integral(face, kinds, band.p_is_u, 1)?
665    };
666    let mut out = Vec::with_capacity(kinds.len());
667    for (&kind, strip) in kinds.iter().zip(strips) {
668        let value = if band.complement {
669            integrate_untrimmed(face, kind)? - strip
670        } else {
671            strip
672        };
673        out.push(value);
674    }
675    Ok(Some(out))
676}