Skip to main content

brep_kernel/blending/blend/
mod.rs

1//! General rolling-ball blends (Golovanov §4.9 + §6.9 surgery).
2//!
3//! The blend surface is the rational surface (4.9.1) built from three
4//! curves: the two support curves cr(t) ⊂ F1 and cs(t) ⊂ F2 where a
5//! sphere of radius ρ touches both faces, and the middle curve at the
6//! intersection of the two tangent planes, weighted w(t) = cos(α/2)
7//! (4.9.3).  The tangency system (4.9.2) + the section-plane constraint
8//! (4.9.5) — the sphere center pinned to the normal plane of the edge —
9//! is solved per station by Newton with a finite-difference Jacobian.
10//!
11//! Topology is rebuilt the §6.9 way, with NO tool solid and NO boolean:
12//! each mating face's loop swaps the blended edge's coedge for a coedge
13//! on its support curve (trimming the adjacent seam edge when the face
14//! is a closed carrier with seam-in-one-loop topology), and the blend
15//! face is inserted with its own seam.  Open edges take the same route
16//! (`edge/open.rs`), and a whole SELECTION goes through `network.rs`, which
17//! marches every stripe against the original solid and solves each shared
18//! corner from its ball before anything is cut.
19
20mod carve;
21mod chain;
22mod collapse;
23mod corner;
24mod edge;
25mod fold;
26mod miter;
27mod network;
28mod planar_chart;
29mod restrict;
30pub(super) mod rollover;
31mod rollover_surgery;
32pub(crate) mod runout;
33mod stations;
34mod rows;
35mod track_fit;
36
37thread_local! {
38    static REFINE_OFF_FOR_TEST: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
39}
40
41/// Whether the open march's local station refinement runs. Escape hatch
42/// `BREP_BLEND_REFINE=0` restores the uniform ladder exactly; a test turns it
43/// off for its own thread with `set_refinement_off_for_test`.
44pub(crate) fn station_refinement_on() -> bool {
45    std::env::var("BREP_BLEND_REFINE").as_deref() != Ok("0")
46        && !REFINE_OFF_FOR_TEST.with(|off| off.get())
47}
48
49thread_local! {
50    static BLEND_NOTES: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
51}
52
53thread_local! {
54    static EXACT_LANE_FOR_TEST: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
55    static EXACT_REVOLUTION_FOR_TEST: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
56    static EXACT_RIM_LANE_FOR_TEST: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
57    static ACCEPT_ONCE_FOR_TEST: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
58    static CHAIN_ARC_STATIONS_FOR_TEST: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
59    static TAPER_NETWORK_FOR_TEST: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
60    static ROLLOVER_FOR_TEST: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
61    static EXACT_TRIM_FOR_TEST: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
62    static UNEQUAL_MITER_FOR_TEST: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
63    static NO_COMMON_BALL_STAR_FOR_TEST: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
64    static CROSSED_RAILS_FOR_TEST: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
65}
66
67/// Off, the network names the stripe `crossed_rails` and the selection
68/// falls through to the cutter composition, whose body is the union of the
69/// canal removals (a ridge). `BREP_BLEND_CROSSED_RAILS=0` is the escape
70/// hatch, `=1` forces it on; `set_crossed_rails_for_test` forces either way
71/// on a test's thread.
72const CROSSED_RAILS_DEFAULT: bool = true;
73
74pub(crate) fn crossed_rails_on() -> bool {
75    if let Some(forced) = CROSSED_RAILS_FOR_TEST.with(|forced| forced.get()) {
76        return forced;
77    }
78    match std::env::var("BREP_BLEND_CROSSED_RAILS").as_deref() {
79        Ok("0") => false,
80        Ok("1") => true,
81        _ => CROSSED_RAILS_DEFAULT,
82    }
83}
84
85/// Off, the network refuses `unequal_corner_radius` or `n_sided_corner` by
86/// name as before. `BREP_BLEND_RIBBON_STAR=0` is the escape hatch, `=1`
87/// forces it on; `set_no_common_ball_star_for_test` forces it on a test's
88/// thread.
89const NO_COMMON_BALL_STAR_DEFAULT: bool = true;
90
91pub(crate) fn no_common_ball_star_on() -> bool {
92    if let Some(forced) = NO_COMMON_BALL_STAR_FOR_TEST.with(|forced| forced.get()) {
93        return forced;
94    }
95    match std::env::var("BREP_BLEND_RIBBON_STAR").as_deref() {
96        Ok("0") => false,
97        Ok("1") => true,
98        _ => NO_COMMON_BALL_STAR_DEFAULT,
99    }
100}
101
102/// The corner has no common ball; the point both shared-face rails stop at is
103/// their rail/rail point (`solve_rail_rail_point`), the seam between the blends
104/// is marched on the larger stripe at the smaller stripe's own canal level, and
105/// — the seam leaving the smaller strip through its side rail — the larger
106/// stripe runs onto that side face and is closed by its section there (the
107/// asymmetric miter's connector), so the side face gets two rail vertices.
108/// `BREP_BLEND_UNEQUAL_MITER=0` is the escape hatch, `=1` forces it on;
109/// `set_unequal_miter_for_test` forces it on a test's thread.
110const UNEQUAL_MITER_DEFAULT: bool = true;
111
112pub(crate) fn unequal_miter_on() -> bool {
113    if let Some(forced) = UNEQUAL_MITER_FOR_TEST.with(|forced| forced.get()) {
114        return forced;
115    }
116    match std::env::var("BREP_BLEND_UNEQUAL_MITER").as_deref() {
117        Ok("0") => false,
118        Ok("1") => true,
119        _ => UNEQUAL_MITER_DEFAULT,
120    }
121}
122
123/// Off, the closed lane names the rollover and declines as it did when the
124/// detection landed (`rollover.rs`), and the ladder falls through to the
125/// cutter, whose body carries a crease. `BREP_BLEND_ROLLOVER=0` is the
126/// escape hatch, `=1` forces it on; `set_rollover_for_test` forces either
127/// way on a test's own thread.
128const ROLLOVER_DEFAULT: bool = true;
129
130pub(crate) fn rollover_on() -> bool {
131    if let Some(forced) = ROLLOVER_FOR_TEST.with(|forced| forced.get()) {
132        return forced;
133    }
134    match std::env::var("BREP_BLEND_ROLLOVER").as_deref() {
135        Ok("0") => false,
136        Ok("1") => true,
137        _ => ROLLOVER_DEFAULT,
138    }
139}
140
141/// OPEN stripes: a straight edge between planar mates is one section
142/// extruded, so the network marches its stations once and skips the ladder's
143/// refinement, fold probe, dense centres and ball-resolve wall reading (the
144/// wall is read off the exact rows instead); the body is the ladder's, byte
145/// for byte. Landed behind this switch, off by default, until the census
146/// promotes it: `BREP_BLEND_EXACT_LANE=1` turns it on, `=0` off; a test
147/// forces either way on its own thread with `set_exact_lane_for_test`.
148const EXACT_LANE_DEFAULT: bool = true;
149
150/// The exact torus is not the fitted rows' body. `BREP_BLEND_EXACT_RIM=1` turns
151/// it on, `=0` off; `set_exact_rim_lane_for_test` forces it on a test's own
152/// thread.
153const EXACT_RIM_LANE_DEFAULT: bool = true;
154
155pub(crate) fn exact_rim_lane_on() -> bool {
156    if let Some(forced) = EXACT_RIM_LANE_FOR_TEST.with(|forced| forced.get()) {
157        return forced;
158    }
159    match std::env::var("BREP_BLEND_EXACT_RIM").as_deref() {
160        Ok("0") => false,
161        Ok("1") => true,
162        _ => EXACT_RIM_LANE_DEFAULT,
163    }
164}
165
166pub(crate) fn exact_lane_on() -> bool {
167    if let Some(forced) = EXACT_LANE_FOR_TEST.with(|forced| forced.get()) {
168        return forced;
169    }
170    match std::env::var("BREP_BLEND_EXACT_LANE").as_deref() {
171        Ok("0") => false,
172        Ok("1") => true,
173        _ => EXACT_LANE_DEFAULT,
174    }
175}
176
177/// The exact rows are not the fitted rows' bytes, so the lane landed behind
178/// this switch OFF and was promoted ON by its census (the whole kernel
179/// battery, the 96-case manifest at its baseline and the render HLR pins
180/// with the switch on). `BREP_BLEND_EXACT_REVOLUTION=0` is the hatch back to
181/// the fitted ladder, `=1` forces the lane; a test forces either way on its
182/// own thread with `set_exact_revolution_for_test`. The lane declines by
183/// name to the ladder (`count.march.exact_revolution_declined`) whenever its
184/// witness does not hold.
185const EXACT_REVOLUTION_DEFAULT: bool = true;
186
187pub(crate) fn exact_revolution_on() -> bool {
188    if let Some(forced) = EXACT_REVOLUTION_FOR_TEST.with(|forced| forced.get()) {
189        return forced;
190    }
191    match std::env::var("BREP_BLEND_EXACT_REVOLUTION").as_deref() {
192        Ok("0") => false,
193        Ok("1") => true,
194        _ => EXACT_REVOLUTION_DEFAULT,
195    }
196}
197
198/// The surgery lane's exact support trim (slice 1b of the box fillet's
199/// hotspots): on an EXACT EXTRUSION row whose mate is a plane, the rail is
200/// a straight line and the plane's (u, v) is affine, so the rail's support
201/// trim is the straight line in uv between the feet of the rail's two ends,
202/// read once as shipped at the pcurve floor (`exact_planar_trim`); the
203/// march trim's read, the track refit and its three reads are skipped. The
204/// line is not the refit's curve (two control points against its fitted
205/// knots), so the body's pcurve bits move on every box-like fixture; the
206/// lane shipped behind this switch, off, until its census (the surgery
207/// record of 2026-10-11: battery, suites and case gate unchanged, one HLR
208/// input digest moving in pcurve bytes with its drawing byte-identical)
209/// re-pinned what moves, and it is ON by default since 2026-10-12.
210/// `BREP_BLEND_EXACT_TRIM=0` is the escape hatch back to the refit road
211/// (`=1` the lane, as before); `set_exact_trim_for_test` forces either way
212/// on a test's own thread.
213const EXACT_TRIM_DEFAULT: bool = true;
214
215pub(crate) fn exact_trim_on() -> bool {
216    if let Some(forced) = EXACT_TRIM_FOR_TEST.with(|forced| forced.get()) {
217        return forced;
218    }
219    match std::env::var("BREP_BLEND_EXACT_TRIM").as_deref() {
220        Ok("0") => false,
221        Ok("1") => true,
222        _ => EXACT_TRIM_DEFAULT,
223    }
224}
225
226/// Same landing rule and switch shape as [`exact_lane_on`]:
227/// `BREP_BLEND_ACCEPT_ONCE=1` / `=0`, default below.
228const ACCEPT_ONCE_DEFAULT: bool = true;
229
230pub(crate) fn accept_once_on() -> bool {
231    if let Some(forced) = ACCEPT_ONCE_FOR_TEST.with(|forced| forced.get()) {
232        return forced;
233    }
234    match std::env::var("BREP_BLEND_ACCEPT_ONCE").as_deref() {
235        Ok("0") => false,
236        Ok("1") => true,
237        _ => ACCEPT_ONCE_DEFAULT,
238    }
239}
240
241/// Equal-length chains are unchanged either way; unequal ones move their
242/// stations, so it lands behind this switch until its census:
243/// `BREP_BLEND_CHAIN_ARC_STATIONS=1` turns it on, `=0` off;
244/// `set_chain_arc_stations_for_test` forces it on a test's own thread.
245const CHAIN_ARC_STATIONS_DEFAULT: bool = false;
246
247pub(crate) fn chain_arc_stations_on() -> bool {
248    if let Some(forced) = CHAIN_ARC_STATIONS_FOR_TEST.with(|forced| forced.get()) {
249        return forced;
250    }
251    match std::env::var("BREP_BLEND_CHAIN_ARC_STATIONS").as_deref() {
252        Ok("0") => false,
253        Ok("1") => true,
254        _ => CHAIN_ARC_STATIONS_DEFAULT,
255    }
256}
257
258/// `BREP_BLEND_TAPER_NETWORK=0` is the escape hatch, `=1` forces it on;
259/// `set_taper_network_for_test` forces it on a test's own thread.
260const TAPER_NETWORK_DEFAULT: bool = true;
261
262pub(crate) fn taper_network_on() -> bool {
263    if let Some(forced) = TAPER_NETWORK_FOR_TEST.with(|forced| forced.get()) {
264        return forced;
265    }
266    match std::env::var("BREP_BLEND_TAPER_NETWORK").as_deref() {
267        Ok("0") => false,
268        Ok("1") => true,
269        _ => TAPER_NETWORK_DEFAULT,
270    }
271}
272
273#[derive(Clone, Debug, PartialEq)]
274pub(crate) enum StripeRadius {
275    Constant(f64),
276    /// `(edge fraction, radius)` stops sorted by fraction, interpolated
277    /// linearly between and held flat past the ends: `blend_edge_variable`'s
278    /// convention, which the law entries sample into.
279    Stops(Vec<(f64, f64)>),
280}
281
282impl StripeRadius {
283    /// From a stop list: one constant when every stop carries the same
284    /// radius (to 1e-12 relative, the single-edge lane's own test).
285    pub(crate) fn from_stops(stops: &[(f64, f64)]) -> Self {
286        let mut sorted = stops.to_vec();
287        sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
288        match sorted.first() {
289            Some(&(_, first)) if sorted.iter().all(|(_, r)| (r - first).abs() <= 1e-12 * (1.0 + first.abs())) => {
290                StripeRadius::Constant(first)
291            }
292            _ => StripeRadius::Stops(sorted),
293        }
294    }
295
296    /// The radius at an edge fraction in `[0, 1]` (clamped).
297    pub(crate) fn at_fraction(&self, fraction: f64) -> f64 {
298        match self {
299            StripeRadius::Constant(radius) => *radius,
300            StripeRadius::Stops(stops) => {
301                let fraction = fraction.clamp(0.0, 1.0);
302                if fraction <= stops[0].0 {
303                    return stops[0].1;
304                }
305                for pair in stops.windows(2) {
306                    if fraction <= pair[1].0 {
307                        let width = (pair[1].0 - pair[0].0).max(1e-12);
308                        return pair[0].1 + (pair[1].1 - pair[0].1) * (fraction - pair[0].0) / width;
309                    }
310                }
311                stops[stops.len() - 1].1
312            }
313        }
314    }
315
316    /// The constant, for a constant law.
317    pub(crate) fn constant(&self) -> Option<f64> {
318        match self {
319            StripeRadius::Constant(radius) => Some(*radius),
320            StripeRadius::Stops(_) => None,
321        }
322    }
323
324    /// The largest radius the law takes.
325    pub(crate) fn largest(&self) -> f64 {
326        match self {
327            StripeRadius::Constant(radius) => *radius,
328            StripeRadius::Stops(stops) => stops.iter().map(|(_, r)| *r).fold(0.0, f64::max),
329        }
330    }
331
332    /// The law's END value at the edge's start (`at_start`) or finish: what
333    /// the user asked for at that vertex, and the constant every corner solve
334    /// there uses (C2).
335    pub(crate) fn end(&self, at_start: bool) -> f64 {
336        self.at_fraction(if at_start { 0.0 } else { 1.0 })
337    }
338
339    /// Whether every radius is positive and finite.
340    pub(crate) fn is_positive(&self) -> bool {
341        match self {
342            StripeRadius::Constant(radius) => radius.is_finite() && *radius > 0.0,
343            StripeRadius::Stops(stops) => !stops.is_empty() && stops.iter().all(|(_, r)| r.is_finite() && *r > 0.0),
344        }
345    }
346}
347
348
349
350
351
352
353
354
355/// Record what a blend that SHIPS needs its feature to say about it — today,
356/// a march whose rails still stand off their carriers after its station
357/// ladder and local refinement ran out. Drained per feature by the history
358/// loop ([`take_blend_notes`]), beside the soundness acceptance's repairs.
359pub(crate) fn record_blend_note(note: String) {
360    BLEND_NOTES.with(|notes| notes.borrow_mut().push(note));
361}
362
363/// Take every blend note recorded on this thread since the last call.
364/// Blending runs on the feature's own thread (no parallel march), so the
365/// history loop's drain sees every note its feature recorded.
366pub fn take_blend_notes() -> Vec<String> {
367    BLEND_NOTES.with(|notes| std::mem::take(&mut *notes.borrow_mut()))
368}
369
370/// The ledger's length, and a way back to it: a lane that refuses takes back
371/// the notes it recorded, so a note never describes a wall that did not ship.
372pub(crate) fn blend_notes_mark() -> usize {
373    BLEND_NOTES.with(|notes| notes.borrow().len())
374}
375
376pub(crate) fn blend_notes_truncate(mark: usize) {
377    BLEND_NOTES.with(|notes| notes.borrow_mut().truncate(mark));
378}
379
380/// A fillet chain wall that SHIPPED short of the construction request its
381/// accepted rung made (`KernelTolerances::model` on the wall between its
382/// rails): which wall — the blend face's name and its exact surface — what
383/// was asked, what the wall reads, and the typed budget that ran out.
384#[derive(Clone, Debug)]
385pub(crate) struct WallModelReport {
386    pub(crate) name: Option<String>,
387    pub(crate) surface: crate::NurbsSurface,
388    pub(crate) request: f64,
389    pub(crate) residual: f64,
390    /// What else the lane measured that the message must say (an unaccepted
391    /// rung's deficit, a ladder rung's local refinement), verbatim.
392    pub(crate) detail: Option<String>,
393    pub(crate) budget: crate::ApproximationBudget,
394}
395
396thread_local! {
397    static BLEND_APPROXIMATIONS: std::cell::RefCell<Vec<WallModelReport>> = const { std::cell::RefCell::new(Vec::new()) };
398}
399
400thread_local! {
401    static BLEND_OPERATION_DEPTH: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
402}
403
404/// One blend OPERATION's scope on this thread: every public blend entry point
405/// (and the history loop, around each feature) holds one. The OUTERMOST
406/// entry drops whatever an earlier operation recorded and nobody consumed —
407/// a direct API caller consumes ([`take_blend_approximations`]) after the
408/// operation whose reports it wants, before starting the next — while a
409/// nested entry (a fillet's per-edge chain blend) clears nothing, so every
410/// report of the operation in flight is kept, however many there are.
411pub(crate) struct BlendOperation;
412
413impl BlendOperation {
414    pub(crate) fn enter() -> BlendOperation {
415        BLEND_OPERATION_DEPTH.with(|depth| {
416            if depth.get() == 0 {
417                BLEND_APPROXIMATIONS.with(|reports| reports.borrow_mut().clear());
418                timing::begin_operation();
419            }
420            depth.set(depth.get() + 1);
421        });
422        BlendOperation
423    }
424}
425
426impl Drop for BlendOperation {
427    fn drop(&mut self) {
428        BLEND_OPERATION_DEPTH.with(|depth| {
429            depth.set(depth.get().saturating_sub(1));
430            if depth.get() == 0 {
431                timing::end_operation();
432            }
433        });
434    }
435}
436
437/// Phase timing of the blend lanes, on the same switch as the boolean's
438/// `boolean.<stage>_ms` lines: set `BREP_PROFILE` and the outermost blend
439/// operation prints one `blend.<measure>=<ms>` line per measure to stderr when
440/// it ends. Off (one `Cell` read per hook), nothing is recorded.
441///
442/// Measures ACCUMULATE: a stage that runs per stripe, per rung or per
443/// component adds to one entry and bumps its count, so the ledger reads as a
444/// split of the operation, not as the worst instance. `timing.*` entries are
445/// milliseconds, `count.*` entries are plain counters.
446pub(crate) mod timing {
447    use std::cell::{Cell, RefCell};
448    use web_time::Instant;
449
450    /// One ledger entry: the measure's name, its accumulated milliseconds
451    /// (zero for a `count.*` entry) and how many times it was recorded.
452    #[derive(Clone, Debug, PartialEq)]
453    pub struct BlendTiming {
454        pub measure: String,
455        pub ms: f64,
456        pub count: u64,
457    }
458
459    thread_local! {
460        static CAPTURE: Cell<bool> = const { Cell::new(false) };
461        static ENABLED: Cell<bool> = const { Cell::new(false) };
462        static TO_STDERR: Cell<bool> = const { Cell::new(false) };
463        static LEDGER: RefCell<Vec<BlendTiming>> = const { RefCell::new(Vec::new()) };
464    }
465
466    /// Turn the ledger on (or off) for the blend operations that FOLLOW on
467    /// this thread; `BREP_PROFILE` turns it on for every operation.
468    pub fn blend_timing_capture(on: bool) {
469        CAPTURE.with(|capture| capture.set(on));
470    }
471
472    /// Take every entry recorded on this thread since the last call (or the
473    /// last outermost operation's start), sorted by measure name.
474    pub fn take_blend_timings() -> Vec<BlendTiming> {
475        let mut entries = LEDGER.with(|ledger| std::mem::take(&mut *ledger.borrow_mut()));
476        entries.sort_by(|a, b| a.measure.cmp(&b.measure));
477        entries
478    }
479
480    /// Whether the hooks record anything right now.
481    pub(crate) fn enabled() -> bool {
482        ENABLED.with(|enabled| enabled.get())
483    }
484
485    pub(super) fn begin_operation() {
486        let profile = std::env::var_os("BREP_PROFILE").is_some();
487        TO_STDERR.with(|to_stderr| to_stderr.set(profile));
488        let on = profile || CAPTURE.with(|capture| capture.get());
489        ENABLED.with(|enabled| enabled.set(on));
490        if on {
491            LEDGER.with(|ledger| ledger.borrow_mut().clear());
492        }
493    }
494
495    pub(super) fn end_operation() {
496        if TO_STDERR.with(|to_stderr| to_stderr.get()) {
497            let mut entries = LEDGER.with(|ledger| ledger.borrow().clone());
498            entries.sort_by(|a, b| a.measure.cmp(&b.measure));
499            for entry in entries {
500                if entry.measure.starts_with("timing.") {
501                    eprintln!("blend.{}={:.3} n={}", entry.measure, entry.ms, entry.count);
502                } else {
503                    eprintln!("blend.{}={}", entry.measure, entry.count);
504                }
505            }
506        }
507        ENABLED.with(|enabled| enabled.set(false));
508    }
509
510    fn add(measure: &str, ms: f64, count: u64) {
511        LEDGER.with(|ledger| {
512            let mut ledger = ledger.borrow_mut();
513            match ledger.iter_mut().find(|entry| entry.measure == measure) {
514                Some(entry) => {
515                    entry.ms += ms;
516                    entry.count += count;
517                }
518                None => ledger.push(BlendTiming { measure: measure.to_string(), ms, count }),
519            }
520        });
521    }
522
523    /// Add `ms` to a `timing.*` measure (one more occurrence).
524    pub(crate) fn record_ms(measure: &str, ms: f64) {
525        if enabled() {
526            add(measure, ms, 1);
527        }
528    }
529
530    /// Add `n` to a `count.*` measure.
531    pub(crate) fn record_count(measure: &str, n: u64) {
532        if enabled() {
533            add(measure, 0.0, n);
534        }
535    }
536
537    /// A stage timer: records its measure when stopped or dropped. Costs one
538    /// flag read when the ledger is off.
539    pub(crate) struct BlendTimer {
540        measure: &'static str,
541        started: Option<Instant>,
542    }
543
544    impl BlendTimer {
545        pub(crate) fn start(measure: &'static str) -> BlendTimer {
546            BlendTimer { measure, started: enabled().then(Instant::now) }
547        }
548
549        /// Milliseconds since the start (zero when the ledger is off).
550        pub(crate) fn elapsed_ms(&self) -> f64 {
551            self.started.map_or(0.0, |started| started.elapsed().as_secs_f64() * 1_000.0)
552        }
553
554        /// Record now and return the reading.
555        pub(crate) fn stop(mut self) -> f64 {
556            let ms = self.elapsed_ms();
557            if self.started.take().is_some() {
558                add(self.measure, ms, 1);
559            }
560            ms
561        }
562    }
563
564    impl Drop for BlendTimer {
565        fn drop(&mut self) {
566            if let Some(started) = self.started.take() {
567                add(self.measure, started.elapsed().as_secs_f64() * 1_000.0, 1);
568            }
569        }
570    }
571}
572pub use timing::{blend_timing_capture, take_blend_timings, BlendTiming};
573pub(crate) use timing::{record_count, BlendTimer};
574
575/// Record a wall that ships with its request unmet. Written by the rung that
576/// keeps its body; whether the body reaches a caller is decided at
577/// consumption ([`take_blend_approximations`]), not here. Every report of
578/// the operation in flight is kept (see [`BlendOperation`]).
579pub(crate) fn record_wall_model_report(report: WallModelReport) {
580    BLEND_APPROXIMATIONS.with(|reports| reports.borrow_mut().push(report));
581}
582
583
584/// Whether any wall-model report is waiting (the history loop reads the
585/// returned bodies only then).
586pub(crate) fn blend_approximations_pending() -> bool {
587    BLEND_APPROXIMATIONS.with(|reports| !reports.borrow().is_empty())
588}
589
590/// Bit-identical surfaces: the same degrees, knots and homogeneous control
591/// points, compared by their bits.
592pub(crate) fn same_surface(a: &crate::NurbsSurface, b: &crate::NurbsSurface) -> bool {
593    let bits = |values: &[f64]| values.iter().map(|value| value.to_bits()).collect::<Vec<_>>();
594    a.degree_u == b.degree_u
595        && a.degree_v == b.degree_v
596        && bits(&a.knots_u) == bits(&b.knots_u)
597        && bits(&a.knots_v) == bits(&b.knots_v)
598        && a.control_points.len() == b.control_points.len()
599        && a.control_points.iter().zip(&b.control_points).all(|(row_a, row_b)| {
600            row_a.len() == row_b.len()
601                && row_a.iter().zip(row_b).all(|(p, q)| {
602                    [p.x, p.y, p.z, p.w].map(f64::to_bits) == [q.x, q.y, q.z, q.w].map(f64::to_bits)
603                })
604        })
605}
606
607/// Whether `face_name` names the report's wall: its exact name, or that
608/// name with the `[k]` (decimal) suffix `ensure_unique_face_names` gives a
609/// colliding name at registration. Nothing else: no prefix, no other suffix.
610fn names_the_wall(face_name: &Option<String>, report_name: &Option<String>) -> bool {
611    match (face_name, report_name) {
612        (Some(face), Some(report)) => {
613            face == report
614                || face.strip_prefix(report.as_str()).and_then(|rest| rest.strip_prefix('[')).and_then(|rest| rest.strip_suffix(']')).is_some_and(
615                    |index| !index.is_empty() && index.bytes().all(|byte| byte.is_ascii_digit()),
616                )
617        }
618        (None, None) => true,
619        _ => false,
620    }
621}
622
623/// Consume EVERY wall-model report recorded on this thread since the last
624/// call, in ONE snapshot, against ALL the bodies a call returned
625/// (`(body name, solid)`): a report becomes a typed `blend.wall_model`
626/// [`crate::Approximation`] on each body carrying its wall — a face with a
627/// bit-identical surface AND the report's name, exactly or as registration
628/// renamed it (`NAME[k]`) — and is dropped when no returned body carries it
629/// (a lane that refused, a body a later step discarded). A wall carried by
630/// two returned bodies (one surface, bit for bit, in both) is reported on
631/// both: the reading is a property of that surface. Reports for one wall
632/// collapse to the last. Never a match by name alone.
633/// `take_blend_approximations(&[])` discards everything recorded (the
634/// history loop's stale drain before a feature runs).
635pub fn take_blend_approximations(bodies: &[(&str, &crate::BrepSolid)]) -> Vec<crate::Approximation> {
636    let snapshot = BLEND_APPROXIMATIONS.with(|reports| std::mem::take(&mut *reports.borrow_mut()));
637    let mut unique: Vec<WallModelReport> = Vec::new();
638    for report in snapshot {
639        unique.retain(|kept| !(kept.name == report.name && same_surface(&kept.surface, &report.surface)));
640        unique.push(report);
641    }
642    let mut approximations = Vec::new();
643    for (body, solid) in bodies {
644        for report in &unique {
645            let carried = solid.shells.iter().flat_map(|shell| &shell.faces).any(|face| {
646                names_the_wall(&face.name, &report.name) && same_surface(&face.surface, &report.surface)
647            });
648            if carried {
649                let wall = report.name.as_deref().unwrap_or("(unnamed)");
650                let unaccepted = matches!(report.budget.reason, crate::BudgetReason::Unaccepted | crate::BudgetReason::Unread);
651                let budget = match report.budget.mechanism {
652                    crate::BudgetMechanism::LocalRounds => format!(
653                        "local refinement stopped: {:?} after {} of {} rounds, {} of {} stations per segment",
654                        report.budget.reason, report.budget.rounds_used, report.budget.rounds_limit,
655                        report.budget.stations, report.budget.station_limit,
656                    ),
657                    crate::BudgetMechanism::StationRungs => format!(
658                        "the open march's station ladder stopped: {:?} after {} of {} global station rungs, {} of {} stations",
659                        report.budget.reason, report.budget.rounds_used, report.budget.rounds_limit,
660                        report.budget.stations, report.budget.station_limit,
661                    ),
662                };
663                let unknown = report.budget.unread > 0;
664                let rails = report.budget.measured_component == crate::MeasuredComponent::Rails;
665                let mut message = if !unaccepted && (rails || unknown) {
666                    // A request that covers the rails and the wall says WHICH
667                    // it measured, and never offers a partial reading as the
668                    // worst: with unread intervals the worst is unknown.
669                    let what = if rails {
670                        format!("the fillet wall {wall}'s support rails stand")
671                    } else {
672                        format!("the fillet wall {wall} stands")
673                    };
674                    let against = if rails {
675                        "off their exact construction (their carriers / the exact ball contacts)"
676                    } else {
677                        "from the rolling ball between its rails"
678                    };
679                    let reading = if unknown {
680                        format!(
681                            "an UNKNOWN distance {against}: {} interval(s) could not be read (the largest READABLE reading is {:.3e} mm, not the worst)",
682                            report.budget.unread, report.residual
683                        )
684                    } else {
685                        format!("{:.3e} mm {against}", report.residual)
686                    };
687                    format!(
688                        "{what} {reading}, against a construction request of {:.3e} mm (accepted at the intersection-fit bar); {budget}",
689                        report.request,
690                    )
691                } else if unaccepted {
692                    format!(
693                        "the blend wall {wall} ships from a rung that never met acceptance: its worst reading \
694                         {:.3e} mm against its acceptance bar {:.3e} mm; {budget}",
695                        report.residual, report.request,
696                    )
697                } else {
698                    format!(
699                        "the fillet wall {wall} stands {:.3e} mm from the rolling ball between its rails, against a \
700                         construction request of {:.3e} mm (accepted at the intersection-fit bar); {budget}",
701                        report.residual, report.request,
702                    )
703                };
704                if let Some(detail) = &report.detail {
705                    message = format!("{message} ({detail})");
706                }
707                approximations.push(crate::Approximation {
708                    code: if unaccepted { "blend.wall_acceptance" } else { "blend.wall_model" }.to_string(),
709                    body: body.to_string(),
710                    measured: report.residual,
711                    bar: report.request,
712                    volume_bound: None,
713                    edges: Vec::new(),
714                    budget: Some(report.budget.clone()),
715                    message,
716                });
717            }
718        }
719    }
720    approximations
721}
722
723
724pub use chain::{blend_smooth_chain, blend_smooth_chain_if_closed};
725pub(crate) use corner::round_concave_chain_corner;
726pub use corner::round_convex_corner;
727pub use edge::{blend_closed_edge, blend_edge_variable, blend_open_edge};
728pub(crate) use collapse::is_full_width_collapse;
729pub(crate) use planar_chart::{
730    fit_planar_charts_to_trims, is_planar_chart_refusal, planar_chart_overrun,
731};
732pub(crate) use track_fit::PCURVE_OFF_FLOOR;
733pub(crate) use edge::{consumed_band, is_consumed_snap, is_rail_collapse};
734pub(crate) use network::{
735    blend_star_network, blend_star_network_laws, blend_star_network_with_corner_policy, degenerate_corner_setbacks, mixed_convexity_corner, mixed_convexity_corners, selection_convexity, DegenerateSetback,
736    MixedCorner,
737};
738pub(crate) use miter::{is_marched_fit_off_carriers, is_miter_support_overrun};
739pub(crate) use runout::{build_runout_walls, plan_runout, take_runout_reports, RunoutPlan, RunoutWallReport};
740pub(crate) use fold::{is_wall_fold, WALL_FOLDS};
741pub(crate) use stations::is_ball_off_carrier;