Skip to main content

brep_kernel/blending/blend/chain/
closed.rs

1use crate::{KernelRefusal, KernelStage, OrRefuse};
2use super::*;
3
4/// Piece of a support rim assigned to one mate face.
5pub(super) struct RimPiece {
6    pub(super) face_id: u64,
7    pub(super) loop_index: usize,
8    /// Global-parameter window of this piece.
9    pub(super) window: [f64; 2],
10    pub(super) edge_id: u64,
11}
12
13/// Blend a chain of conjugated edges with one rolling-ball blend face
14/// (Golovanov §6.9.5: all conjugated edges processed together).  A CLOSED
15/// chain (stadium rim, T-pipe saddle) welds into a periodic blend; an OPEN
16/// chain (line->arc->line capped by end faces) rolls a clamped blend that
17/// terminates in a transverse edge on each end face.
18pub fn blend_smooth_chain(
19    solid: &BrepSolid,
20    seed_edge_id: u64,
21    radius: f64,
22    chamfer: bool,
23    name: Option<&str>,
24) -> Result<BrepSolid, KernelRefusal> {
25    if !(radius > 0.0) || !radius.is_finite() {
26        return Err(KernelRefusal::input(KernelStage::Collect, "radius", "blend: radius must be positive"));
27    }
28    let chain = collect_smooth_chain(solid, seed_edge_id)?;
29    if chain.segments.len() < 2 {
30        return Err(KernelRefusal::internal(KernelStage::Refine, "chain_single_segment", "blend: chain collapsed to a single segment"));
31    }
32    if chain.closed {
33        blend_closed_smooth_chain(solid, &chain.segments, radius, chamfer, name)
34    } else {
35        blend_open_smooth_chain(solid, &chain, radius, chamfer, name)
36    }
37}
38
39/// `blend_smooth_chain` restricted to CLOSED chains: an edge that is one arc
40/// of a seam-split rim (a cyl×cyl saddle) is blended as the whole rim, but an
41/// OPEN tangent chain is not walked — filleting one edge of it must not
42/// fillet the unselected edges it runs into.  Tangent propagation is a
43/// selection policy for the feature layer; the kernel caps an unselected
44/// continuation instead (`network.rs`).
45pub fn blend_smooth_chain_if_closed(
46    solid: &BrepSolid,
47    seed_edge_id: u64,
48    radius: f64,
49    chamfer: bool,
50    name: Option<&str>,
51) -> Result<BrepSolid, KernelRefusal> {
52    if !(radius > 0.0) || !radius.is_finite() {
53        return Err(KernelRefusal::input(KernelStage::Collect, "radius", "blend: radius must be positive"));
54    }
55    let chain = collect_smooth_chain(solid, seed_edge_id)?;
56    if chain.segments.len() < 2 {
57        return Err(KernelRefusal::internal(KernelStage::Refine, "chain_single_segment", "blend: chain collapsed to a single segment"));
58    }
59    if !chain.closed {
60        return Err(
61            KernelRefusal::unsupported(KernelStage::Classify, "open_tangent_chain", "blend: the edge is one arc of an OPEN tangent chain; the unselected \
62             continuation is capped, not blended"),
63        );
64    }
65    blend_closed_smooth_chain(solid, &chain.segments, radius, chamfer, name)
66}
67
68/// Blend a CLOSED chain of conjugated edges with one rolling-ball blend
69/// face (Golovanov §6.9.5: all conjugated edges processed together).
70///
71/// A wall whose ball-centre curve turns tighter than the ball FOLDS, and the
72/// envelope a plain march sweeps is then not the blend (`blend/fold.rs`).  It
73/// is not refused here any more where the fold is a lens the wall closes on
74/// itself: the chain is re-marched up a measured ladder of budgets and the lens
75/// is carved out along the crease (`blend/carve.rs`).  The re-march is the
76/// whole of the extra cost and only a folding wall pays it — the first march is
77/// the ordinary one, and its refusal is what asks for the second.
78fn blend_closed_smooth_chain(
79    solid: &BrepSolid,
80    segments: &[ChainSegment<'_>],
81    radius: f64,
82    chamfer: bool,
83    name: Option<&str>,
84) -> Result<BrepSolid, KernelRefusal> {
85    let bar = crate::KernelTolerances::for_solid(solid, 1e-7).intersection_fit;
86    // The plain collar climbs the same measured ladder the carve does, from the
87    // same first rung: a budget picked once is a resolution request in disguise.
88    let mut per_segment = CHAIN_PER_SEGMENT;
89    let first = loop {
90        let rung = build_closed_smooth_chain(
91            solid,
92            segments,
93            radius,
94            chamfer,
95            name,
96            per_segment,
97            FoldPolicy::Refuse,
98            bar,
99        );
100        match rung {
101            Ok(Rung::TooCoarse { deviation }) => {
102                crate::blend::carve::carve_trace(format_args!(
103                    "blend chain: {per_segment} stations per segment leaves the rails \
104                     {deviation:.6e} from the rolling ball, against {bar:.3e}"
105                ));
106                if per_segment * 2 > CHAIN_CARVE_MAX_PER_SEGMENT {
107                    return Err(KernelRefusal::non_convergence(KernelStage::Refine, "chain_ladder_top", format!(
108                        "blend: at {per_segment} stations per segment — the top of the chain's \
109                         ladder — this collar's fitted rails are still {deviation:.6e} from the \
110                         rolling ball's own contacts against a bar of {bar:.3e}, so there is no \
111                         wall accurate enough to build"
112                    )));
113                }
114                per_segment *= 2;
115            }
116            other => break other,
117        }
118    };
119    if let Err(error) = &first {
120        crate::blend::carve::carve_trace(format_args!(
121            "blend chain: the march at {per_segment} stations per segment refused: {error}"
122        ));
123    }
124    match first {
125        Ok(Rung::Built(built)) => Ok(built),
126        Ok(Rung::TooCoarse { .. }) => {
127            Err(KernelRefusal::internal(KernelStage::Refine, "chain_ladder_rung", "blend: the chain ladder left an accuracy rung unhandled"))
128        }
129        // A fold that reaches a RAIL is not a lens: every section of that wall
130        // is singular somewhere between its contacts, so there is nothing to cut
131        // away and keep, and the ladder below would only re-march it to its top
132        // before the carve refused the same band. Its refusal names the face it
133        // cannot cross, and it is terminal as it stands (`blend/fold.rs`).
134        Err(error) if crate::blend::fold::is_rail_fold(&error) => Err(error),
135        // A CHAMFER section is a straight line and has no ball centre to read
136        // the fold locus against, so its fold stays the refusal it was.
137        Err(error) if crate::blend::is_wall_fold(&error) && !chamfer => {
138            carve_ladder(solid, segments, radius, name, bar)
139                // A CARVE THAT FAILS IS STILL A FOLD, and the fold is terminal.
140                // If the carve's own refusal did not read as one, the ladder
141                // below would answer a proven-folding centre curve with the
142                // cutter's unrelated complaint — which is the exact failure
143                // `fold.rs` was made terminal to stop. So every exit from this
144                // branch carries the fold's own prefix, with the carve's reason
145                // inside it.
146                .map_err(|carve_error| {
147                    if crate::blend::is_wall_fold(&carve_error) {
148                        carve_error
149                    } else {
150                        crate::blend::fold::wall_fold_refusal(
151                            Vec::new(),
152                            format!(
153                                "{} {} fits this edge: the wall folds, and the fold band could not \
154                                 be carved — {carve_error}",
155                                crate::blend::WALL_FOLDS,
156                                radius.abs()
157                            ),
158                        )
159                    }
160                })
161        }
162        Err(error) => Err(error),
163    }
164}
165
166/// Re-march a folding chain up a ladder of station budgets and carve at the
167/// first rung whose wall IS the rolling ball's to `bar`.
168///
169/// The budget is not chosen: a larger or a tighter fillet needs a different
170/// rung, and a number picked once from one fixture would be a resolution
171/// request in disguise. Each rung is measured by the rolling ball itself — its
172/// contacts re-solved halfway between stations, where an interpolant is worst —
173/// against the fitted rails, and the ladder stops at the first rung inside
174/// `intersection_fit`. A wall that still misses at the top is refused by name.
175fn carve_ladder(
176    solid: &BrepSolid,
177    segments: &[ChainSegment<'_>],
178    radius: f64,
179    name: Option<&str>,
180    bar: f64,
181) -> Result<BrepSolid, KernelRefusal> {
182    let mut per_segment = CHAIN_PER_SEGMENT;
183    loop {
184        match build_closed_smooth_chain(
185            solid,
186            segments,
187            radius,
188            false,
189            name,
190            per_segment,
191            FoldPolicy::Carve,
192            bar,
193        )? {
194            Rung::Built(built) => {
195                crate::blend::carve::carve_trace(format_args!(
196                    "blend carve: {per_segment} stations per segment is the first rung inside \
197                     {bar:.3e}"
198                ));
199                return Ok(built);
200            }
201            Rung::TooCoarse { deviation } => {
202                crate::blend::carve::carve_trace(format_args!(
203                    "blend carve: {per_segment} stations per segment leaves the rails \
204                     {deviation:.6e} from the rolling ball, against {bar:.3e}"
205                ));
206                if per_segment * 2 > CHAIN_CARVE_MAX_PER_SEGMENT {
207                    return Err(crate::blend::fold::wall_fold_refusal(
208                        Vec::new(),
209                        format!(
210                            "{} {} fits this edge: the wall folds, and at {per_segment} stations \
211                             per segment — the top of the carve's ladder — its fitted rails are \
212                             still {deviation:.6e} from the rolling ball's own contacts against a \
213                             bar of {bar:.3e}, so there is no wall accurate enough to carve",
214                            crate::blend::WALL_FOLDS,
215                            radius.abs()
216                        ),
217                    ));
218                }
219                per_segment *= 2;
220            }
221        }
222    }
223}
224
225/// One rung of a chain build: the finished solid, or — for a carve — the
226/// measured reason this budget is not enough.
227enum Rung {
228    Built(BrepSolid),
229    TooCoarse { deviation: f64 },
230}
231
232/// The worst distance from the rolling ball's halfway contacts to the fitted
233/// rails `cr` and `cs`, each found by a closest-point Newton from the
234/// parameter the chord map assigns the midpoint.
235///
236/// THE NEWTON STAYS WHERE IT WAS SEEDED. The foot of a halfway contact is
237/// inside the station interval it was solved in, so every step is bounded by
238/// that interval's own length in the fit's parameter, clamped to the rail's
239/// domain, and taken only when it brings the rail closer. A plain Newton stops
240/// wherever the distance is stationary along the curve, which a rail that
241/// turns sharply at a chain junction offers well away from the foot. Measured
242/// on a 20 cube rounded at r = 3 with a bottom edge at r = 4 (the wall folds at
243/// the arcs' rails, so this is the ladder's reading with that refusal set
244/// aside): unguarded, every rung read the distance from the halfway contact
245/// beside a junction to the junction itself — 2.927709e-1 at 24 stations per
246/// segment against a half interval of 0.291667, halving per rung to
247/// 9.128591e-3 at 768 — and on two of the eight mirror-image edges a foot
248/// across the chain, 1.299089e1. Guarded, all eight read 6.961233e-3 at 24
249/// and 6.920006e-6 to 6.920008e-6 at 768, falling fourfold per rung as a
250/// cubic's does. A far foot can read short as well as long, which would pass
251/// a rung that misses.
252fn rail_deviation(
253    samples: &[ChainSample],
254    cr: &NurbsCurve,
255    cs: &NurbsCurve,
256    fit_low: f64,
257    fit_range: f64,
258) -> Result<f64, KernelRefusal> {
259    let parameter_of = |segment: usize, position: isize| -> Option<f64> {
260        samples
261            .iter()
262            .find(|sample| sample.segment == segment && sample.position == position)
263            .map(|sample| sample.parameter)
264    };
265    let closest = |curve: &NurbsCurve, target: Vec3, seed: f64, reach: f64| -> Result<f64, KernelRefusal> {
266        let [low, high] = curve.domain().or_refuse(KernelStage::Refine, "domain")?;
267        let distance = |u: f64| -> Result<f64, KernelRefusal> {
268            Ok(curve.derivatives_extended(u, 0).or_refuse(KernelStage::Refine, "derivatives_extended")?[0].sub(target).length())
269        };
270        let mut u = seed.clamp(low, high);
271        let mut best = distance(u)?;
272        for _ in 0..40 {
273            let derivatives = curve.derivatives_extended(u, 2).or_refuse(KernelStage::Refine, "derivatives_extended")?;
274            let offset = derivatives[0].sub(target);
275            let slope = offset.dot(derivatives[1]);
276            let curvature = derivatives[1].dot(derivatives[1]) + offset.dot(derivatives[2]);
277            // Off a minimum's basin the Newton step points the wrong way; walk
278            // downhill by the bound instead and let the test below shorten it.
279            let mut step = if curvature > 0.0 {
280                (slope / curvature).clamp(-reach, reach)
281            } else {
282                reach.copysign(slope)
283            };
284            let mut trial = (u - step).clamp(low, high);
285            let mut trial_distance = distance(trial)?;
286            for _ in 0..30 {
287                if trial_distance < best {
288                    break;
289                }
290                step *= 0.5;
291                trial = (u - step).clamp(low, high);
292                trial_distance = distance(trial)?;
293            }
294            if !(trial_distance < best) {
295                break;
296            }
297            let moved = (trial - u).abs();
298            u = trial;
299            best = trial_distance;
300            if moved < 1e-15 {
301                break;
302            }
303        }
304        Ok(best)
305    };
306    let mut worst: f64 = 0.0;
307    // Each segment's worst halfway contact, for `BREP_BLEND_CARVE_TRACE=1`: where
308    // along the chain a rung misses is what tells a junction from a bend.
309    let mut per_segment: Vec<(f64, isize, f64, f64, Vec3, Vec3)> = Vec::new();
310    for sample in samples {
311        let Some([p1, p2]) = sample.midpoint else {
312            continue;
313        };
314        let Some(next) = parameter_of(sample.segment, sample.position + 1) else {
315            continue;
316        };
317        let global = (0.5 * (sample.parameter + next)).rem_euclid(1.0);
318        let seed = (global - fit_low) / fit_range;
319        let reach = (next - sample.parameter).abs() / fit_range;
320        let (miss1, miss2) = (closest(cr, p1, seed, reach)?, closest(cs, p2, seed, reach)?);
321        worst = worst.max(miss1).max(miss2);
322        if per_segment.len() <= sample.segment {
323            per_segment.resize(
324                sample.segment + 1,
325                (0.0, 0, 0.0, 0.0, Vec3::default(), Vec3::default()),
326            );
327        }
328        if miss1.max(miss2) >= per_segment[sample.segment].0 {
329            per_segment[sample.segment] = (miss1.max(miss2), sample.position, miss1, miss2, p1, p2);
330        }
331    }
332    for (segment, (miss, position, miss1, miss2, p1, p2)) in per_segment.iter().enumerate() {
333        crate::blend::carve::carve_trace(format_args!(
334            "  rail miss segment {segment}: worst {miss:.6e} after station {position} \
335             (cr {miss1:.6e} at ({:.6}, {:.6}, {:.6}), cs {miss2:.6e} at ({:.6}, {:.6}, {:.6}))",
336            p1.x, p1.y, p1.z, p2.x, p2.y, p2.z
337        ));
338    }
339    Ok(worst)
340}
341
342fn build_closed_smooth_chain(
343    solid: &BrepSolid,
344    segments: &[ChainSegment<'_>],
345    radius: f64,
346    chamfer: bool,
347    name: Option<&str>,
348    per_segment: usize,
349    fold: FoldPolicy,
350    bar: f64,
351) -> Result<Rung, KernelRefusal> {
352    let samples = march_chain(segments, radius, false, per_segment, fold)?;
353
354    // Global rows from the in-segment samples (wrapped-overlap closed fit).
355    let mut global: Vec<(f64, &ChainSample)> = samples
356        .iter()
357        .filter(|sample| (0..per_segment as isize).contains(&sample.position))
358        .map(|sample| (sample.parameter.rem_euclid(1.0), sample))
359        .collect();
360    global.sort_by(|a, b| a.0.total_cmp(&b.0));
361    let count = global.len();
362    let degree = FIT_DEGREE;
363    let mut extended_params = Vec::new();
364    let mut samples_cr = Vec::new();
365    let mut samples_mid = Vec::new();
366    let mut samples_cs = Vec::new();
367    let mut push = |station: &Station, parameter: f64| {
368        extended_params.push(parameter);
369        samples_cr.push(Vec4::from_point(station.p1, 1.0));
370        samples_cs.push(Vec4::from_point(station.p2, 1.0));
371        samples_mid.push(Vec4 {
372            x: station.apex.x * station.weight,
373            y: station.apex.y * station.weight,
374            z: station.apex.z * station.weight,
375            w: station.weight,
376        });
377    };
378    for offset in (1..=degree).rev() {
379        let (parameter, sample) = &global[count - offset];
380        push(&sample.station, parameter - 1.0);
381    }
382    for (parameter, sample) in &global {
383        push(&sample.station, *parameter);
384    }
385    // Close the loop: repeat the first station at parameter 1, then the
386    // wrap continuation.
387    push(&global[0].1.station, global[0].0 + 1.0);
388    for offset in 1..=degree {
389        let (parameter, sample) = &global[offset];
390        push(&sample.station, parameter + 1.0);
391    }
392    let low = extended_params[0];
393    let high = *extended_params.last().unwrap();
394    let range = high - low;
395    let normalized: Vec<f64> = extended_params
396        .iter()
397        .map(|parameter| (parameter - low) / range)
398        .collect();
399    let seam_low = (0.0 - low) / range;
400    let seam_high = (1.0 - low) / range;
401    let fit_row = |row: &[Vec4]| -> Result<NurbsCurve, KernelRefusal> {
402        let curve = fit::interpolate_homogeneous(row, degree, &normalized).or_refuse(KernelStage::Refine, "interpolate_homogeneous")?;
403        let (_, tail) = curve.split(seam_low).or_refuse(KernelStage::Refine, "split")?;
404        let (middle, _) = tail.split(seam_high).or_refuse(KernelStage::Refine, "split")?;
405        Ok(middle)
406    };
407    let cr = fit_row(&samples_cr)?;
408    let cs = fit_row(&samples_cs)?;
409    let mid = if chamfer {
410        None
411    } else {
412        Some(fit_row(&samples_mid)?)
413    };
414    let u_domain = cr.domain().or_refuse(KernelStage::Refine, "domain")?;
415    let surface = crate::blend::rows::surface_from_rows(degree, &cr, &cs, mid.as_ref(), true)?;
416
417    // The CARVE.  A wall that folds carries a lens of parameter inside the
418    // swept ball volume; the blend is that wall with the lens cut away along
419    // the crease where the two sheets meet, and the crease becomes an edge of
420    // the blend with two coedges on this one face.  Traced on the surface that
421    // was just fitted — not on the ideal envelope — so the two kept sheets
422    // meet EXACTLY on the curve the trim is built from (`blend/carve.rs`).
423    // EVERY rung is measured before anything is built on it, carve or not: the
424    // rails are interpolants through the stations, and between stations they
425    // can leave the rolling ball's own contacts — and so the carriers they are
426    // supposed to ride — by far more than the kernel's intersection-fit
427    // contract. A carve pays for surgery only on an accurate rung; a plain
428    // collar pays nothing more than the measurement unless it misses.
429    let deviation = rail_deviation(&samples, &cr, &cs, low, range)?;
430    if deviation > bar {
431        return Ok(Rung::TooCoarse { deviation });
432    }
433    crate::blend::carve::carve_trace(format_args!(
434        "blend chain: {per_segment} stations per segment leaves the rails {deviation:.6e} \
435         from the rolling ball, inside {bar:.3e}"
436    ));
437    let crease = if fold == FoldPolicy::Carve {
438        // THE BAR IS THE KERNEL'S OWN CONTRACT FOR THE QUANTITY MEASURED. What
439        // the fit is held to is "does this coedge's curve-on-surface, pushed
440        // back to 3-D, still trace the same locus as the edge's 3-D curve?",
441        // which is `pcurve_consistency` — not `intersection_fit`, which is the
442        // accuracy of a fitted intersection CURVE and is what the re-marched
443        // wall itself is held to (`carve_ladder`). Measured on the
444        // 2026-09-02 collar the crease's pcurves reach 5.4e-6 against that
445        // 4e-3, so the margin is three decades and the bar is not what decides
446        // the case either way.
447        let bar = crate::KernelTolerances::for_solid(solid, 1e-7).pcurve_consistency;
448        let Some(carved) = crate::blend::carve::trace_wall_crease(&surface, radius.abs())? else {
449            return Err(KernelRefusal::internal(KernelStage::Refine, "carve_no_fold", format!(
450                "blend carve: the wall re-marched at {per_segment} stations per segment carries                  no fold to carve, but the march at {CHAIN_PER_SEGMENT} refused one"
451            )));
452        };
453        Some(crate::blend::carve::fit_crease(&surface, &carved, bar)?)
454    } else {
455        None
456    };
457
458    // Single-face sides (e.g. one cap around the whole rim) get ONE uv
459    // fit in the rows' own normalized space — identical parameterization
460    // to cr/cs, so the closed support piece and its pcurve agree exactly.
461    let single_face = |side: usize| -> bool {
462        let first_id = segments[0].mate(side).face.id;
463        segments
464            .iter()
465            .all(|segment| segment.mate(side).face.id == first_id)
466    };
467    let mut whole_pcurves: [Option<NurbsCurve>; 2] = [None, None];
468    for side in 0..2 {
469        if !single_face(side) {
470            continue;
471        }
472        let mut row = Vec::new();
473        let select = |station: &Station| -> [f64; 2] {
474            if side == 0 {
475                station.uv1
476            } else {
477                station.uv2
478            }
479        };
480        let mut push_uv = |station: &Station| {
481            let uv = select(station);
482            row.push(Vec4::from_point(Vec3::new(uv[0], uv[1], 0.0), 1.0));
483        };
484        for offset in (1..=degree).rev() {
485            push_uv(&global[count - offset].1.station);
486        }
487        for (_, sample) in &global {
488            push_uv(&sample.station);
489        }
490        push_uv(&global[0].1.station);
491        for offset in 1..=degree {
492            push_uv(&global[offset].1.station);
493        }
494        whole_pcurves[side] = Some(fit_row(&row)?);
495    }
496
497    // Per-face pcurve fits over each segment's full sample window
498    // (in-segment + overshoot), in global parameters.
499    let mut pcurves1 = Vec::with_capacity(segments.len());
500    let mut pcurves2 = Vec::with_capacity(segments.len());
501    for segment in 0..segments.len() {
502        let mut window: Vec<&ChainSample> = samples
503            .iter()
504            .filter(|sample| sample.segment == segment)
505            .collect();
506        window.sort_by(|a, b| a.parameter.total_cmp(&b.parameter));
507        let params: Vec<f64> = window.iter().map(|sample| sample.parameter).collect();
508        let low = params[0];
509        let high = *params.last().unwrap();
510        let normalized: Vec<f64> = params
511            .iter()
512            .map(|parameter| (parameter - low) / (high - low))
513            .collect();
514        let fit_uv = |select: &dyn Fn(&Station) -> [f64; 2]| -> Result<NurbsCurve, KernelRefusal> {
515            let points: Vec<Vec4> = window
516                .iter()
517                .map(|sample| {
518                    let uv = select(&sample.station);
519                    Vec4::from_point(Vec3::new(uv[0], uv[1], 0.0), 1.0)
520                })
521                .collect();
522            let curve = fit::interpolate_homogeneous(&points, degree, &normalized).or_refuse(KernelStage::Refine, "interpolate_homogeneous")?;
523            // Re-express in GLOBAL parameters: the curve's [0,1] domain
524            // corresponds to [low, high]; keep as-is and remember the
525            // affine map through the window bounds.
526            Ok(curve)
527        };
528        pcurves1.push((low, high, fit_uv(&|station| station.uv1)?));
529        pcurves2.push((low, high, fit_uv(&|station| station.uv2)?));
530    }
531
532    Ok(Rung::Built(chain_surgery(
533        solid,
534        segments,
535        ChainRows {
536            surface,
537            cr,
538            cs,
539            u_domain,
540            fit_low: low,
541            fit_range: range,
542            pcurves1,
543            pcurves2,
544            whole_pcurves,
545            crease,
546        },
547        name,
548    )?))
549}
550
551pub(super) struct ChainRows {
552    pub(super) surface: NurbsSurface,
553    pub(super) cr: NurbsCurve,
554    pub(super) cs: NurbsCurve,
555    pub(super) u_domain: [f64; 2],
556    /// Affine map from the rows' fit space to GLOBAL chain parameters:
557    /// global = fit_low + p * fit_range (the global fit normalised its
558    /// wrap-extended parameters onto [0, 1] before interpolating).
559    pub(super) fit_low: f64,
560    pub(super) fit_range: f64,
561    /// Per-segment (window_low, window_high, uv curve over [0,1]) in
562    /// GLOBAL parameters.
563    pub(super) pcurves1: Vec<(f64, f64, NurbsCurve)>,
564    pub(super) pcurves2: Vec<(f64, f64, NurbsCurve)>,
565    /// Whole-chain uv fits (rows' fit space) for single-face sides.
566    pub(super) whole_pcurves: [Option<NurbsCurve>; 2],
567    /// The crease a CARVED wall is cut along: the inner loop of the blend
568    /// face, one edge carrying two coedges of that same face.
569    pub(super) crease: Option<crate::blend::carve::CarvedCrease>,
570}
571
572impl ChainRows {
573    pub(super) fn to_global(&self, fit_parameter: f64) -> f64 {
574        self.fit_low + fit_parameter * self.fit_range
575    }
576}
577
578/// Extract the pcurve portion for a global window [a, b] from a
579/// segment's fitted uv curve, re-parameterised so its domain maps
580/// affinely onto the portion (matching the split support edge).
581pub(super) fn pcurve_portion(fitted: &(f64, f64, NurbsCurve), a: f64, b: f64) -> Result<NurbsCurve, KernelRefusal> {
582    let (low, high, curve) = fitted;
583    // The chain parameterisation wraps with period 1; pick the period
584    // copy of the window that overlaps this segment's fit range.
585    let to_local = |value: f64| {
586        let mut best = value;
587        for candidate in [value, value + 1.0, value - 1.0] {
588            if candidate >= low - 1e-9 && candidate <= high + 1e-9 {
589                best = candidate;
590                break;
591            }
592        }
593        ((best - low) / (high - low)).clamp(0.0, 1.0)
594    };
595    let mut local_a = to_local(a);
596    let mut local_b = to_local(b);
597    if local_a > local_b {
598        std::mem::swap(&mut local_a, &mut local_b);
599    }
600    let epsilon = 1e-9;
601    let (_, tail) = if local_a > epsilon {
602        curve.split(local_a).or_refuse(KernelStage::Refine, "split")?
603    } else {
604        (curve.clone(), curve.clone())
605    };
606    let tail = if local_a > epsilon {
607        tail
608    } else {
609        curve.clone()
610    };
611    let portion = if local_b < 1.0 - epsilon {
612        tail.split(local_b).or_refuse(KernelStage::Refine, "split")?.0
613    } else {
614        tail
615    };
616    Ok(portion)
617}
618
619/// The single boundary edge of a side's mate face(s) incident to a chain
620/// vertex, EXCLUDING the two chain edges meeting there — i.e. the SEAM
621/// (same face on both segments) or the SPOKE (face changes) that the
622/// support curve must cross.  `None` when the chain simply flows through
623/// (same face, no seam — e.g. a stadium cap rim vertex).
624pub(super) fn cross_edge_at(
625    solid: &BrepSolid,
626    face_before: &FaceRecord,
627    loop_before: usize,
628    face_after: &FaceRecord,
629    loop_after: usize,
630    vertex: u64,
631    before_edge: u64,
632    after_edge: u64,
633) -> Result<Option<u64>, KernelRefusal> {
634    let mut found: Option<u64> = None;
635    let mut consider = |face: &FaceRecord, loop_index: usize| -> Result<(), KernelRefusal> {
636        for coedge in &face.loops[loop_index].coedges {
637            if coedge.edge_id == before_edge || coedge.edge_id == after_edge {
638                continue;
639            }
640            let edge = solid
641                .edges
642                .iter()
643                .find(|edge| edge.id == coedge.edge_id)
644                .ok_or(KernelRefusal::internal(KernelStage::Refine, "missing_edge", "blend: loop references missing edge"))?;
645            if edge.start_vertex_id == vertex || edge.end_vertex_id == vertex {
646                if found.is_some() && found != Some(edge.id) {
647                    return Err(KernelRefusal::unsupported(KernelStage::Classify, "chain_cross_edges", "blend: multiple cross edges at a chain vertex"));
648                }
649                found = Some(edge.id);
650            }
651        }
652        Ok(())
653    };
654    consider(face_before, loop_before)?;
655    if !(face_after.id == face_before.id && loop_after == loop_before) {
656        consider(face_after, loop_after)?;
657    }
658    Ok(found)
659}
660
661/// The nearest periodic image of a seam boundary to `value` — the boundary a
662/// crossing endpoint must be pinned to, read off its ADJACENT INTERIOR sample.
663fn nearest_seam_image(value: f64, low: f64, high: f64) -> f64 {
664    let period = high - low;
665    low + ((value - low) / period).round() * period
666}
667
668/// The mate-face pcurve of a support PIECE, built by projecting the piece's
669/// 3D curve onto the (analytic) mate surface and fitting.  Because a chain
670/// support rides EXACTLY on its mate carrier, the projection is exact; the
671/// periodic track is unwrapped so it never jumps a period across the carrier
672/// seam, and crossing endpoints are pinned ONTO the seam so the piece and the
673/// trimmed seam edge close the loop in parameter space.
674///
675/// The carrier is closed in EITHER direction, and the crossed seam is not
676/// always the u meridian.  A cylinder/cone/sphere is closed in u only, so its
677/// seam is the u0 ≡ u1 meridian — but a TORUS is biperiodic, and a tube welded
678/// through a plane crosses the torus' v0 ≡ v1 PARALLEL (its outer equator)
679/// instead.  Pinning u there drags the endpoint a half-carrier away from its
680/// own 3D vertex (the collar loop then closes on a uv corner that is nowhere
681/// near the vertex, and the mate face's trim is garbage), so the pinned AXIS is
682/// chosen GEOMETRICALLY: whichever closed direction's seam actually passes
683/// through the endpoint.  A carrier closed in u only can only ever take the u
684/// branch, exactly as before.
685pub(super) fn project_piece_pcurve(
686    piece: &NurbsCurve,
687    surface: &NurbsSurface,
688    start_is_crossing: bool,
689    end_is_crossing: bool,
690) -> Result<NurbsCurve, KernelRefusal> {
691    let [u0, u1] = surface.domain_u().or_refuse(KernelStage::Refine, "domain_u")?;
692    let [v0, v1] = surface.domain_v().or_refuse(KernelStage::Refine, "domain_v")?;
693    let (_, closed_v) = surface.closed_directions().or_refuse(KernelStage::Refine, "closed_directions")?;
694    let period = u1 - u0;
695    let period_v = v1 - v0;
696    let [d0, d1] = piece.domain().or_refuse(KernelStage::Refine, "domain")?;
697    // Dense reference projection; the pcurve is fit from an adaptively
698    // thinned subset kept as coarse as tolerance allows.
699    //
700    // The track is as dense as the PIECE is, not a fixed 96. A carved wall's
701    // re-marched rail carries hundreds of control points (574 on the
702    // 2026-09-02 two-torus collar), and a pcurve fitted and checked at 97
703    // samples of it was accepted 1.19e-3 off the rail it claims.
704    let dense = (8 * piece.control_points.len()).clamp(96, 4096);
705    let mut proj_u = Vec::with_capacity(dense + 1);
706    let mut proj_v = Vec::with_capacity(dense + 1);
707    let mut point3 = Vec::with_capacity(dense + 1);
708    let mut params = Vec::with_capacity(dense + 1);
709    let mut previous_u: Option<f64> = None;
710    // The foot each sample continues from: the track is ONE branch of the
711    // carrier, so after the first sample each inversion starts from its
712    // neighbour's foot rather than asking for the nearest point of the whole
713    // surface, which on a carrier that comes back near itself is another sheet.
714    let mut previous_foot: Option<[f64; 2]> = None;
715    let mut feet: Vec<[f64; 2]> = Vec::with_capacity(dense + 1);
716    for section in 0..=dense {
717        let t = d0 + (d1 - d0) * section as f64 / dense as f64;
718        let point = piece.evaluate(t).or_refuse(KernelStage::Refine, "evaluate")?;
719        let projection = match previous_foot {
720            None => crate::project_point_to_surface(surface, point).or_refuse(KernelStage::Refine, "project_point_to_surface")?,
721            Some(seed) => crate::projection::project_point_to_surface_from_seed(surface, point, seed).or_refuse(KernelStage::Refine, "project_point_to_surface_from_seed")?,
722        };
723        previous_foot = Some([projection.u, projection.v]);
724        feet.push([projection.u, projection.v]);
725        let mut u = projection.u;
726        if let Some(previous) = previous_u {
727            while u - previous > period * 0.5 {
728                u -= period;
729            }
730            while previous - u > period * 0.5 {
731                u += period;
732            }
733        }
734        previous_u = Some(u);
735        proj_u.push(u);
736        proj_v.push(projection.v);
737        point3.push(point);
738        params.push(section as f64 / dense as f64);
739    }
740    // A biperiodic carrier needs the SAME unwrap in v, or a track that walks up
741    // to the v seam comes back as 0 at the far end and the fit swings across
742    // the whole carrier.  Anchor the chain on the first INTERIOR sample and
743    // pull index 0 onto it afterwards: an endpoint sitting exactly ON the seam
744    // inverts to either boundary at the solver's whim, and letting that
745    // coin flip anchor the chain would shift the whole piece a period.
746    if closed_v {
747        for index in 2..=dense {
748            while proj_v[index] - proj_v[index - 1] > period_v * 0.5 {
749                proj_v[index] -= period_v;
750            }
751            while proj_v[index - 1] - proj_v[index] > period_v * 0.5 {
752                proj_v[index] += period_v;
753            }
754        }
755        while proj_v[0] - proj_v[1] > period_v * 0.5 {
756            proj_v[0] -= period_v;
757        }
758        while proj_v[1] - proj_v[0] > period_v * 0.5 {
759            proj_v[0] += period_v;
760        }
761    }
762    // Interior mean decides which meridian a crossing endpoint sits on, and
763    // whether the whole track needs a full-period shift into the domain.
764    let mean_u: f64 = proj_u[1..dense].iter().sum::<f64>() / (dense - 1) as f64;
765    let seam_u = if mean_u - u0 > u1 - mean_u { u1 } else { u0 };
766    // Pin a crossing endpoint onto the seam it actually crosses.  Each
767    // candidate keeps the OTHER coordinate as projected, so the losing axis
768    // moves the endpoint bodily off its own 3D vertex — the residual names the
769    // winner with no surface-type special-casing.  u keeps the whole-track
770    // mean; a v run can legitimately cover a full period, so its boundary is
771    // read off the ADJACENT INTERIOR sample instead.  Both pins land in the
772    // track's own (pre-shift) period, so the shift below carries them along
773    // with the rest of the track.
774    let pinned = |index: usize,
775                  neighbour: usize,
776                  proj_u: &[f64],
777                  proj_v: &[f64]|
778     -> Result<(f64, f64), KernelRefusal> {
779        let point = point3[index];
780        let u_error = surface
781            .evaluate(seam_u, proj_v[index]).or_refuse(KernelStage::Refine, "evaluate")?
782            .sub(point)
783            .length();
784        if closed_v {
785            let seam_v = nearest_seam_image(proj_v[neighbour], v0, v1);
786            let v_error = surface
787                .evaluate(proj_u[index], seam_v).or_refuse(KernelStage::Refine, "evaluate")?
788                .sub(point)
789                .length();
790            if v_error < u_error {
791                return Ok((proj_u[index], seam_v));
792            }
793        }
794        Ok((seam_u, proj_v[index]))
795    };
796    if start_is_crossing {
797        let (u, v) = pinned(0, 1, &proj_u, &proj_v)?;
798        proj_u[0] = u;
799        proj_v[0] = v;
800    }
801    if end_is_crossing {
802        let (u, v) = pinned(dense, dense - 1, &proj_u, &proj_v)?;
803        proj_u[dense] = u;
804        proj_v[dense] = v;
805    }
806    let mut shift = 0.0;
807    while mean_u + shift > u1 + 1e-9 {
808        shift -= period;
809    }
810    while mean_u + shift < u0 - 1e-9 {
811        shift += period;
812    }
813    for u in proj_u.iter_mut() {
814        *u += shift;
815    }
816    if closed_v {
817        let mean_v: f64 = proj_v[1..dense].iter().sum::<f64>() / (dense - 1) as f64;
818        let mut shift_v = 0.0;
819        while mean_v + shift_v > v1 + 1e-9 {
820            shift_v -= period_v;
821        }
822        while mean_v + shift_v < v0 - 1e-9 {
823            shift_v += period_v;
824        }
825        for v in proj_v.iter_mut() {
826            *v += shift_v;
827        }
828    }
829    // Coarsest interpolation whose fitted pcurve reproduces the projected TRACK
830    // to the kernel's pcurve refinement floor — the floor every other pcurve fit
831    // is held to, and the one the shell vector-area bar is built from.
832    //
833    // It used to stop at 0.002 absolute ("half the validator's tolerance"),
834    // which is `pcurve_consistency / 2` on any model under 20 units and so four
835    // decades looser than any other trim: a torus face carrying a collar's rail
836    // was accepted 1.19e-3 off it, and that one coedge was the whole of a
837    // 1.94e-4 shell vector-area residual, 14.5x its face's bar.
838    //
839    // The deviation is measured against the projected track, not the piece's
840    // 3D points: a pcurve lies on its surface by construction, so a piece that
841    // is itself off the carrier would hold the ladder at its top however fine
842    // the fit, and that is a RAIL defect for the march to answer, not this fit.
843    let tolerance = crate::pcurve::PCURVE_REFINEMENT_TOLERANCE;
844    // The ladder is the one verified fitter (`track_fit`), which checks every
845    // rung at the MIDPOINTS between dense samples, projected on their own —
846    // checked at the dense samples, the top rung, which interpolates every one
847    // of them, reads zero by construction and passes whatever lies between (on
848    // the 574-control-point collar rail it read 9.6e-15) — and breaks the fit,
849    // C1, where the track crosses a knot line of the carrier.
850    let at = |fraction: f64| piece.evaluate(d0 + (d1 - d0) * fraction);
851    let mut track = crate::blend::track_fit::Track {
852        fractions: params,
853        uv: proj_u.iter().zip(&proj_v).map(|(u, v)| [*u, *v]).collect(),
854        feet,
855        breaks: Vec::new(),
856    };
857    let breaks = crate::blend::track_fit::curve_breaks(piece, d0, d1, true);
858    crate::blend::track_fit::polish_track(surface, &at, &mut track)?;
859    crate::blend::track_fit::insert_knot_crossings(surface, &at, &mut track, &breaks)?;
860    let fit = crate::blend::track_fit::fit_track(surface, &track, &at, tolerance)?;
861    if std::env::var("BREP_DEBUG_TRACK_FIT").is_ok() {
862        eprintln!(
863            "TRACK_FIT closed-chain piece: miss {:.3e} at {} samples of {} ({} breaks), on floor {}",
864            fit.miss,
865            fit.samples,
866            track.fractions.len(),
867            track.breaks.len(),
868            fit.on_floor
869        );
870    }
871    // A fit off the floor is refused, not handed on as the best rung. Before the
872    // fitter broke at knot lines this ladder returned its best rung silently,
873    // and on the 2026-09-02 two-torus collar that was 1.07e-7 to 8.46e-7 off at
874    // the top rung on every collar that builds (measured 2026-09-17); broken,
875    // each of them reaches the floor. What still misses is a rail the march tore
876    // (2.7 to 3.3 on the oversized radii, a body refused or rejected anyway).
877    if !fit.on_floor {
878        return Err(KernelRefusal::non_convergence(KernelStage::Refine, "chain_pcurve_floor", format!(
879            "{} a closed chain's support piece misses its pcurve by {:.3e} at {} samples, \
880             against a floor of {tolerance:.1e}",
881            crate::blend::PCURVE_OFF_FLOOR,
882            fit.miss,
883            fit.samples
884        )));
885    }
886    Ok(fit.curve)
887}