Skip to main content

brep_kernel/blending/blend/edge/
closed.rs

1use crate::{KernelRefusal, KernelStage, OrRefuse};
2use super::*;
3
4/// General closed-edge rolling-ball fillet or chamfer by direct §6.9
5/// topology surgery.
6pub fn blend_closed_edge(
7    solid: &BrepSolid,
8    edge_id: u64,
9    radius: f64,
10    chamfer: bool,
11    name: Option<&str>,
12) -> Result<BrepSolid, KernelRefusal> {
13    if !(radius > 0.0) || !radius.is_finite() {
14        return Err(KernelRefusal::input(KernelStage::Collect, "radius", "blend: radius must be positive"));
15    }
16    blend_closed_edge_impl(solid, edge_id, &|_| radius, chamfer, name)
17}
18
19/// Variable-radius blend (4.9.5): radius stops as (edge fraction, radius)
20/// pairs, linearly interpolated along the edge parameter and clamped at
21/// the ends.  Closed edges must supply matching first/last radii.
22pub fn blend_edge_variable(
23    solid: &BrepSolid,
24    edge_id: u64,
25    radii: &[(f64, f64)],
26    chamfer: bool,
27    name: Option<&str>,
28) -> Result<BrepSolid, KernelRefusal> {
29    if radii.is_empty() || radii.iter().any(|(_, radius)| !(*radius > 0.0)) {
30        return Err(KernelRefusal::input(KernelStage::Collect, "radius_stop", "blend: every radius stop must be positive"));
31    }
32    let mut stops = radii.to_vec();
33    stops.sort_by(|a, b| a.0.total_cmp(&b.0));
34    let edge = solid
35        .edges
36        .iter()
37        .find(|edge| edge.id == edge_id)
38        .ok_or_else(|| format!("blend: edge {edge_id} not found")).or_refuse(KernelStage::Refine, "ok_or_else")?;
39    let closed = edge.start_vertex_id == edge.end_vertex_id;
40    if closed && (stops[0].1 - stops[stops.len() - 1].1).abs() > 1e-12 {
41        return Err(KernelRefusal::input(KernelStage::Collect, "closed_edge_radii", "blend: closed edges need equal first/last radii"));
42    }
43    // CONSTANT stops must DEGENERATE to the exact constant-radius
44    // machinery (§6.9 exact cylinder cutters where the mates allow them,
45    // the same general march otherwise).  The general march's fitted
46    // NURBS is geometrically exact for a constant radius, but downstream
47    // booleans treat it as a generic surface: marched SSI curves against
48    // a tangent mate drift O(√ε) along the tangency (measured 1.2e-3 on
49    // a 10-box chain miter), while the exact representation intersects
50    // analytically — this is what keeps chain-corner miters composable.
51    let first_radius = stops[0].1;
52    if stops
53        .iter()
54        .all(|(_, radius)| (radius - first_radius).abs() <= 1e-12 * (1.0 + first_radius.abs()))
55    {
56        return if chamfer {
57            crate::fillet::chamfer_edge(solid, edge_id, first_radius, name)
58        } else {
59            crate::fillet::fillet_edge(solid, edge_id, first_radius, name)
60        };
61    }
62    let (t0, t1) = (edge.t0, edge.t1);
63    let radius_at = move |t: f64| -> f64 {
64        let raw = (t - t0) / (t1 - t0);
65        // Closed edges WRAP (the anchored march may start mid-edge and run
66        // past t1 once around — clamping misplaced mid-profile stops
67        // there); open edges CLAMP flat past the ends, which is only the
68        // marched overshoot region beyond the trims.  The blend-end
69        // stations are pinned exactly at t0/t1 by the open march, so the
70        // profile kink the clamp creates sits ON an interpolation node and
71        // the fitted rows still pass through the closed-form end stations.
72        let fraction = if closed {
73            raw.rem_euclid(1.0)
74        } else {
75            raw.clamp(0.0, 1.0)
76        };
77        if fraction <= stops[0].0 {
78            return stops[0].1;
79        }
80        for pair in stops.windows(2) {
81            if fraction <= pair[1].0 {
82                let width = (pair[1].0 - pair[0].0).max(1e-12);
83                let local = (fraction - pair[0].0) / width;
84                return pair[0].1 + (pair[1].1 - pair[0].1) * local;
85            }
86        }
87        stops[stops.len() - 1].1
88    };
89    if closed {
90        blend_closed_edge_impl(solid, edge_id, &radius_at, chamfer, name)
91    } else {
92        blend_open_edge_impl(solid, edge_id, &radius_at, chamfer, name)
93    }
94}
95
96fn blend_closed_edge_impl(
97    solid: &BrepSolid,
98    edge_id: u64,
99    radius_at: &dyn Fn(f64) -> f64,
100    chamfer: bool,
101    name: Option<&str>,
102) -> Result<BrepSolid, KernelRefusal> {
103    let edge = solid
104        .edges
105        .iter()
106        .find(|edge| edge.id == edge_id)
107        .ok_or_else(|| format!("blend: edge {edge_id} not found")).or_refuse(KernelStage::Refine, "ok_or_else")?;
108    if edge.start_vertex_id != edge.end_vertex_id {
109        return Err(KernelRefusal::input(KernelStage::Collect, "closed_edge", "blend: general path currently requires a CLOSED edge"));
110    }
111    let (face_a, loop_a, coedge_a) = locate_mate(solid, edge_id, None)?;
112    let (face_b, loop_b, coedge_b) = locate_mate(solid, edge_id, Some((face_a.id, loop_a)))?;
113    if face_a.id == face_b.id && loop_a == loop_b {
114        return Err(KernelRefusal::unsupported(KernelStage::Classify, "seam_edge", "blend: edge is used twice by one loop (seam edge?)"));
115    }
116
117    // Seam-structured loops force the blend seam onto that carrier's seam
118    // meridian; at most one mate may need it.
119    let seam_a = face_a.loops[loop_a].coedges.len() > 1;
120    let seam_b = face_b.loops[loop_b].coedges.len() > 1;
121    let (first_face, first_loop, first_coedge, second_face, second_loop, second_coedge) =
122        if seam_a || !seam_b {
123            (face_a, loop_a, coedge_a, face_b, loop_b, coedge_b)
124        } else {
125            (face_b, loop_b, coedge_b, face_a, loop_a, coedge_a)
126        };
127    let anchored = first_face.loops[first_loop].coedges.len() > 1;
128    let second_seam = anchored && second_face.loops[second_loop].coedges.len() > 1;
129    let anchor_u = if anchored {
130        // Lock onto the carrier's seam meridian: the u-domain start.
131        Some(first_face.surface.domain_u().or_refuse(KernelStage::Refine, "domain_u")?[0])
132    } else {
133        None
134    };
135
136    // Signed radii from the cross-section seed at the edge midpoint.
137    let mid_radius = radius_at(edge.t0 + (edge.t1 - edge.t0) * 0.5);
138    let (rho1, rho2) = signed_radii(
139        edge,
140        first_face,
141        first_coedge,
142        second_face,
143        second_coedge,
144        mid_radius,
145    )?;
146
147    let first_mate = BlendMate {
148        face: first_face,
149        coedge: first_coedge,
150        loop_index: first_loop,
151        rho: rho1,
152    };
153    let second_mate = BlendMate {
154        face: second_face,
155        coedge: second_coedge,
156        loop_index: second_loop,
157        rho: rho2,
158    };
159    let mid_t = edge.t0 + (edge.t1 - edge.t0) * 0.5;
160    // Anchored FIRST, always — everything that already works takes this rung
161    // and is unchanged.  An anchored march freezes the first carrier's u on
162    // its seam meridian and frees t (`solve_anchored_start`), so it is only
163    // solvable when the edge actually reaches that meridian.  A closed edge
164    // cut by a plane through a surface of revolution's AXIS is one whole
165    // MERIDIAN of that carrier — constant u, a full turn in v — so no station
166    // on it can sit at the seam, and the seam may even have been trimmed off
167    // the face (the 2026-09-01 reported document: the kept torus was
168    // u ∈ [0.1369, 0.8869] while the anchor locked u = 0).  That march reports
169    // "anchored seam start did not converge"; the UNANCHORED march solves the
170    // same edge exactly, because the edge's own endpoints already sit on the
171    // carrier's OTHER (v) seam.  Retrying is what makes this a rung of the
172    // existing ladder rather than a predicate with a tolerance to tune: a
173    // march that needs its anchor still gets one.
174    let marched = match march_stations(edge, &first_mate, &second_mate, radius_at, anchor_u) {
175        Ok(stations) => Ok(stations),
176        Err(anchored_error) if anchor_u.is_some() => {
177            march_stations(edge, &first_mate, &second_mate, radius_at, None).map_err(
178                |retry_error| {
179                    // A FOLD found by the un-anchored march is the SHAPE's
180                    // answer, and it outranks the anchored march's complaint:
181                    // reporting the anchor's non-convergence instead would send
182                    // a proven-folding centre curve on to the edge-preserving
183                    // construction below.
184                    if crate::blend::is_wall_fold(&retry_error) {
185                        retry_error
186                    } else {
187                        anchored_error
188                    }
189                },
190            )
191        }
192        Err(anchored_error) => Err(anchored_error),
193    };
194    let stations = match marched {
195        Ok(stations) => stations,
196        // A wall that FOLDS is refused here for the same reason an escaped
197        // march is: the edge-preserving construction would build a blend on the
198        // same folding centre curve, and if it converged it would return the
199        // very surface this refusal exists to stop.  It is not the fallback's
200        // question to answer, so the geometry is reported as-is.
201        Err(march_error) if crate::blend::is_wall_fold(&march_error) => return Err(march_error),
202        Err(march_error) if crate::blend::is_ball_off_carrier(&march_error) => {
203            // An ESCAPED march is not a non-convergence: the tangency system
204            // had no solution at all, so no fallback can build the blend the
205            // rolling ball never made.  Report the escape itself — routing it
206            // to the edge-preserving construction below only reports THAT
207            // lane's complaint about a face it was never meant to consume
208            // ("ambiguous preserved boundary edge" on the 2026-09-10 report).
209            return Err(march_error);
210        }
211        Err(march_error) => {
212            // A non-converging march often means the rolling ball falls off
213            // one face — try the edge-preserving construction (4.9.7).
214            return blend_closed_edge_keep(
215                solid,
216                edge,
217                &first_mate,
218                &second_mate,
219                radius_at(mid_t),
220                rho1,
221                chamfer,
222                name,
223            )
224            .or_else(|_| {
225                blend_closed_edge_keep(
226                    solid,
227                    edge,
228                    &second_mate,
229                    &first_mate,
230                    radius_at(mid_t),
231                    rho2,
232                    chamfer,
233                    name,
234                )
235            })
236            .map_err(|keep_error| {
237                format!("{march_error}; edge-preserving blend also failed: {keep_error}")
238            }).or_refuse(KernelStage::Refine, "map_err");
239        }
240    };
241    // Support-out-of-trim detection (4.9.7 trigger): a tangency track that
242    // leaves its face's trimmed region cannot be trimmed there — the far
243    // boundary must be PRESERVED instead.
244    let support_exits = |face: &FaceRecord, second_side: bool| -> bool {
245        // Adaptive marches vary the station count; keep at least the old fixed
246        // grid's ~16-probe floor while inheriting extra density where the
247        // march refined (which is where an exit would hide).
248        let stride = (stations.len() / 16).max(1);
249        stations.iter().step_by(stride).any(|station| {
250            let mut uv = if second_side {
251                station.uv2
252            } else {
253                station.uv1
254            };
255            if let Ok((closed_u, closed_v)) = face.surface.closed_directions() {
256                if closed_u {
257                    if let Ok([low, high]) = face.surface.domain_u() {
258                        uv[0] = low + (uv[0] - low).rem_euclid(high - low);
259                    }
260                }
261                if closed_v {
262                    if let Ok([low, high]) = face.surface.domain_v() {
263                        uv[1] = low + (uv[1] - low).rem_euclid(high - low);
264                    }
265                }
266            }
267            crate::parameter_point_in_face(face, crate::Vec2 { x: uv[0], y: uv[1] }, 1e-6)
268                .map(|class| class == crate::PolygonClass::Outside)
269                .unwrap_or(true)
270        })
271    };
272    if support_exits(second_face, true) {
273        return blend_closed_edge_keep(
274            solid,
275            edge,
276            &first_mate,
277            &second_mate,
278            radius_at(mid_t),
279            rho1,
280            chamfer,
281            name,
282        );
283    }
284    if support_exits(first_face, false) {
285        return blend_closed_edge_keep(
286            solid,
287            edge,
288            &second_mate,
289            &first_mate,
290            radius_at(mid_t),
291            rho2,
292            chamfer,
293            name,
294        );
295    }
296    if std::env::var("BREP_DEBUG_BLEND_MARCH").is_ok() {
297        let last = stations.len() - 1;
298        for index in [
299            0usize,
300            1.min(last),
301            2.min(last),
302            last.saturating_sub(1),
303            last,
304        ] {
305            let station = &stations[index];
306            eprintln!(
307                "station {index}: uv1=({:.6},{:.6}) uv2=({:.6},{:.6}) p1=({:.6},{:.6},{:.6}) w={:.6}",
308                station.uv1[0], station.uv1[1], station.uv2[0], station.uv2[1],
309                station.p1.x, station.p1.y, station.p1.z, station.weight,
310            );
311        }
312    }
313    let parameters = station_parameters(&stations);
314    let rows = match exact_closed_revolution_rows(&stations, &first_mate, &second_mate, chamfer) {
315        Some(rows) => rows?,
316        None => fit_closed_rows(&stations, &parameters, chamfer)?,
317    };
318
319    // Both mates seam-structured: the second support crosses ITS carrier's
320    // seam meridian somewhere mid-loop; split cs there so face2's loop can
321    // keep its seam-in-one-loop structure.
322    let second_split = if second_seam {
323        let seam2 = second_face.surface.domain_u().or_refuse(KernelStage::Refine, "domain_u")?[0];
324        let period2 = {
325            let [d0, d1] = second_face.surface.domain_u().or_refuse(KernelStage::Refine, "domain_u")?;
326            d1 - d0
327        };
328        // Bracket: unwrapped uv2 track crossing seam2 + k·period.
329        let u2_first = stations[0].uv2[0];
330        let u2_last = stations[stations.len() - 1].uv2[0];
331        let direction = (u2_last - u2_first).signum();
332        let mut k = ((u2_first - seam2) / period2).ceil();
333        if direction < 0.0 {
334            k = ((u2_first - seam2) / period2).floor();
335        }
336        let target = seam2 + k * period2;
337        let inside =
338            (target - u2_first) * direction > 1e-6 && (u2_last - target) * direction > 1e-6;
339        // ALIGNED seams (both carriers' meridians meet at the shared
340        // vertex — the natural coaxial construction): the crossing sits
341        // at the march boundary and no mid-loop split is needed; the
342        // blend seam vertex already lies on both meridians.
343        let aligned = (target - u2_first).abs() <= 1e-6
344            || (target - u2_last).abs() <= 1e-6
345            || ((u2_first - seam2) / period2).fract().abs() <= 1e-9;
346        if !inside && aligned {
347            None
348        } else if !inside {
349            return Err(KernelRefusal::internal(KernelStage::Refine, "seam_crossing_bracket", "blend: second seam crossing not bracketed by the march"));
350        } else {
351            // Fit-space crossing parameter on the fitted cs (Newton via the
352            // fitted pcurve so the split lands exactly where the FITTED track
353            // crosses the meridian).
354            let [fit_low, fit_high] = rows.u_domain;
355            let mut p = fit_low
356                + (fit_high - fit_low) * {
357                    // Seed from the bracketing stations.
358                    let mut seed = 0.5;
359                    for pair in 0..stations.len() - 1 {
360                        let a = stations[pair].uv2[0];
361                        let b = stations[pair + 1].uv2[0];
362                        if (target - a) * (target - b) <= 0.0 {
363                            let local = (target - a) / (b - a);
364                            seed = (parameters[pair]
365                                + (parameters[pair + 1] - parameters[pair]) * local)
366                                .clamp(0.0, 1.0);
367                            break;
368                        }
369                    }
370                    seed
371                };
372            for _ in 0..NEWTON_ITERATIONS {
373                let value = rows.cs_pcurve.evaluate(p).or_refuse(KernelStage::Refine, "evaluate")?.x - target;
374                if value.abs() <= 1e-12 {
375                    break;
376                }
377                let step = 1e-8;
378                let probed = rows.cs_pcurve.evaluate(p + step).or_refuse(KernelStage::Refine, "evaluate")?.x - target;
379                let derivative = (probed - value) / step;
380                if derivative.abs() <= 1e-14 {
381                    return Err(KernelRefusal::non_convergence(KernelStage::Refine, "seam_crossing_newton", "blend: second seam crossing Newton stalled"));
382                }
383                p -= value / derivative;
384            }
385            let crossing_v = rows.cs_pcurve.evaluate(p).or_refuse(KernelStage::Refine, "evaluate")?.y;
386            Some(SecondSeamSplit {
387                fit_parameter: p,
388                crossing_v,
389                period: period2 * direction,
390            })
391        }
392    } else {
393        None
394    };
395
396    build_surgery(
397        solid,
398        edge,
399        &first_mate,
400        &second_mate,
401        rows,
402        second_split,
403        name,
404    )
405}
406
407/// Where the second support crosses ITS seam-structured carrier's seam
408/// meridian (both-seam closed edges).
409struct SecondSeamSplit {
410    fit_parameter: f64,
411    crossing_v: f64,
412    /// Signed carrier period travelled by the unwrapped pcurve track.
413    period: f64,
414}
415
416/// Replace the blended edge in both mating loops, trim the seam edge of an
417/// anchored closed carrier, and insert the blend face.
418fn build_surgery(
419    solid: &BrepSolid,
420    edge: &EdgeRecord,
421    first: &BlendMate,
422    second: &BlendMate,
423    rows: FittedRows,
424    second_split: Option<SecondSeamSplit>,
425    name: Option<&str>,
426) -> Result<BrepSolid, KernelRefusal> {
427    let mut result = solid.clone();
428    let mut take_id = crate::blend::edge::fresh_id_source(solid);
429
430    let [u_start, u_end] = rows.u_domain;
431    let cr_start = rows.cr.evaluate(u_start).or_refuse(KernelStage::Refine, "evaluate")?;
432    let cs_start = rows.cs.evaluate(u_start).or_refuse(KernelStage::Refine, "evaluate")?;
433    let vertex1_id = take_id();
434    let vertex2_id = take_id();
435    result.vertices.push(VertexRecord {
436        id: vertex1_id,
437        point: cr_start,
438    });
439    result.vertices.push(VertexRecord {
440        id: vertex2_id,
441        point: cs_start,
442    });
443
444    let cr_edge_id = take_id();
445    result.edges.push(EdgeRecord {
446        id: cr_edge_id,
447        curve: rows.cr.clone(),
448        t0: u_start,
449        t1: u_end,
450        start_vertex_id: vertex1_id,
451        end_vertex_id: vertex1_id,
452        degenerate: false,
453        name: None,
454    });
455    // Second support: whole closed edge, or two pieces split where it
456    // crosses ITS carrier's seam meridian (both-seam closed edges).  Each
457    // piece records (edge id, fit-space window, forward pcurve).
458    let mut cs_pieces: Vec<(u64, [f64; 2], NurbsCurve)> = Vec::new();
459    let mut second_seam_vertex = vertex2_id;
460    let mut second_seam_point = cs_start;
461    let mut second_crossing_v = 0.0;
462    if let Some(split) = &second_split {
463        let p = split.fit_parameter;
464        second_crossing_v = split.crossing_v;
465        let (cs_a, cs_b) = rows.cs.split(p).or_refuse(KernelStage::Refine, "split")?;
466        let (pc_a, pc_b) = rows.cs_pcurve.split(p).or_refuse(KernelStage::Refine, "split")?;
467        // Bring the wrapped second piece back into the carrier's domain.
468        let mut pc_b = pc_b;
469        for point in pc_b.control_points.iter_mut() {
470            point.x -= split.period * point.w;
471        }
472        let w2s = take_id();
473        second_seam_vertex = w2s;
474        second_seam_point = rows.cs.evaluate(p).or_refuse(KernelStage::Refine, "evaluate")?;
475        result.vertices.push(VertexRecord {
476            id: w2s,
477            point: second_seam_point,
478        });
479        let edge_a = take_id();
480        result.edges.push(EdgeRecord {
481            id: edge_a,
482            curve: cs_a,
483            t0: u_start,
484            t1: p,
485            start_vertex_id: vertex2_id,
486            end_vertex_id: w2s,
487            degenerate: false,
488            name: None,
489        });
490        cs_pieces.push((edge_a, [u_start, p], pc_a));
491        let edge_b = take_id();
492        result.edges.push(EdgeRecord {
493            id: edge_b,
494            curve: cs_b,
495            t0: p,
496            t1: u_end,
497            start_vertex_id: w2s,
498            end_vertex_id: vertex2_id,
499            degenerate: false,
500            name: None,
501        });
502        cs_pieces.push((edge_b, [p, u_end], pc_b));
503    } else {
504        let cs_edge_id = take_id();
505        result.edges.push(EdgeRecord {
506            id: cs_edge_id,
507            curve: rows.cs.clone(),
508            t0: u_start,
509            t1: u_end,
510            start_vertex_id: vertex2_id,
511            end_vertex_id: vertex2_id,
512            degenerate: false,
513            name: None,
514        });
515        cs_pieces.push((cs_edge_id, [u_start, u_end], rows.cs_pcurve.clone()));
516    }
517    let seam_curve = rows.surface.iso_curve_u(u_start).or_refuse(KernelStage::Refine, "iso_curve_u")?;
518    let [seam_t0, seam_t1] = seam_curve.domain().or_refuse(KernelStage::Refine, "domain")?;
519    let blend_seam_id = take_id();
520    result.edges.push(EdgeRecord {
521        id: blend_seam_id,
522        curve: seam_curve,
523        t0: seam_t0,
524        t1: seam_t1,
525        start_vertex_id: vertex1_id,
526        end_vertex_id: vertex2_id,
527        degenerate: false,
528        name: None,
529    });
530
531    let old_vertex = edge.start_vertex_id;
532    let replace_in_face = |result: &mut BrepSolid,
533                           mate: &BlendMate,
534                           pieces: &[(u64, [f64; 2], NurbsCurve)],
535                           uv_new: [f64; 2]|
536     -> Result<bool, KernelRefusal> {
537        let v_new = uv_new[1];
538        // (edge id, the START vertex is the removed one, the END vertex is).
539        let seam_edge_ends: Vec<(u64, bool, bool)> = result
540            .edges
541            .iter()
542            .filter(|candidate| {
543                candidate.id != edge.id
544                    && (candidate.start_vertex_id == old_vertex
545                        || candidate.end_vertex_id == old_vertex)
546            })
547            .map(|candidate| {
548                (
549                    candidate.id,
550                    candidate.start_vertex_id == old_vertex,
551                    candidate.end_vertex_id == old_vertex,
552                )
553            })
554            .collect();
555        let seam_edge_ids: Vec<u64> = seam_edge_ends.iter().map(|(id, ..)| *id).collect();
556        let face = result
557            .shells
558            .iter_mut()
559            .flat_map(|shell| &mut shell.faces)
560            .find(|face| face.id == mate.face.id)
561            .ok_or(KernelRefusal::internal(KernelStage::Sew, "mate_face", "blend: mate face lost during surgery"))?;
562        let loop_record = &mut face.loops[mate.loop_index];
563        let position = loop_record
564            .coedges
565            .iter()
566            .position(|coedge| coedge.edge_id == edge.id)
567            .ok_or(KernelRefusal::internal(KernelStage::Sew, "edge_coedge", "blend: edge coedge lost during surgery"))?;
568        let old_forward = loop_record.coedges[position].forward;
569        let old_seam_v = {
570            let pcurve = &loop_record.coedges[position].pcurve;
571            let [d0, _] = pcurve.domain().or_refuse(KernelStage::Refine, "domain")?;
572            pcurve.evaluate(d0).or_refuse(KernelStage::Refine, "evaluate")?.y
573        };
574        // Replacement coedges: pieces run in u order; a reversed loop use
575        // takes them in reverse order, each reversed.
576        let mut replacements = Vec::new();
577        let ordered: Vec<&(u64, [f64; 2], NurbsCurve)> = if old_forward {
578            pieces.iter().collect()
579        } else {
580            pieces.iter().rev().collect()
581        };
582        for (index, (edge_id, _, pcurve)) in ordered.iter().enumerate() {
583            replacements.push(CoedgeRecord {
584                id: if index == 0 {
585                    loop_record.coedges[position].id
586                } else {
587                    0 // patched by the caller with fresh ids
588                },
589                edge_id: *edge_id,
590                forward: old_forward,
591                pcurve: if old_forward {
592                    pcurve.clone()
593                } else {
594                    pcurve.reversed().or_refuse(KernelStage::Refine, "reversed")?
595                },
596            });
597        }
598        loop_record
599            .coedges
600            .splice(position..=position, replacements);
601        let mut repaired = false;
602        if loop_record.coedges.len() > 1 {
603            // Anchored carrier: the loop's other coedges ride the seam
604            // meridian between the old edge and the rest of the trim.
605            // Pull the pcurve endpoint that met the old edge down to the
606            // support crossing.  (The seam EDGE itself is trimmed after
607            // both replacements, outside this borrow.)
608            for coedge in &mut loop_record.coedges {
609                if pieces
610                    .iter()
611                    .any(|(edge_id, ..)| *edge_id == coedge.edge_id)
612                    || !seam_edge_ids.contains(&coedge.edge_id)
613                {
614                    continue;
615                }
616                let controls = &mut coedge.pcurve.control_points;
617                let last_index = controls.len() - 1;
618                let first_u = controls[0].x / controls[0].w;
619                let last_u = controls[last_index].x / controls[last_index].w;
620                let constant_u = (first_u - last_u).abs()
621                    <= 1e-9 * (1.0 + first_u.abs().max(last_u.abs()));
622                if constant_u {
623                    // Seam MERIDIAN pcurve: constant u, runs along the v
624                    // direction; the endpoint at the removed edge's v moves
625                    // onto the support crossing.
626                    let first_v = controls[0].y / controls[0].w;
627                    let last_v = controls[last_index].y / controls[last_index].w;
628                    let target = if (first_v - old_seam_v).abs() < (last_v - old_seam_v).abs() {
629                        0
630                    } else {
631                        last_index
632                    };
633                    let w = controls[target].w;
634                    controls[target].y = v_new * w;
635                    continue;
636                }
637                // The adjacent trim runs along U at constant v — the carrier's
638                // OTHER (v) seam, which is what a closed edge lying on one
639                // whole MERIDIAN borders (an axial-plane collar on a torus).
640                // Its endpoint moves in U, not V, and the v-distance rule
641                // above cannot pick the end at all: both ends share the same
642                // v, so it always lands on `last_index`, which is wrong for
643                // one of the seam PAIR (the v-seam edge appears twice in the
644                // loop, at v = 0 and v = 1).  Take the end from the TOPOLOGY.
645                let Some((_, start_is_old, end_is_old)) = seam_edge_ends
646                    .iter()
647                    .find(|(id, ..)| *id == coedge.edge_id)
648                    .copied()
649                else {
650                    continue;
651                };
652                if start_is_old == end_is_old {
653                    return Err(KernelRefusal::ill_posed(KernelStage::Sew, "neighbour_trim_end", format!(
654                        "blend: neighbour edge {} meets the blended edge at both ends; \
655                         its trim end is ambiguous",
656                        coedge.edge_id
657                    )));
658                }
659                // `forward` maps the coedge's pcurve domain start onto the
660                // edge's t0 (its start vertex).
661                let target = if start_is_old == coedge.forward {
662                    0
663                } else {
664                    last_index
665                };
666                let w = controls[target].w;
667                controls[target].x = uv_new[0] * w;
668                repaired = true;
669            }
670        }
671        Ok(repaired)
672    };
673    let uv1_new = rows.cr_pcurve.evaluate(u_start).or_refuse(KernelStage::Refine, "evaluate")?;
674    let v1_new = uv1_new.y;
675    let cr_pieces = vec![(cr_edge_id, [u_start, u_end], rows.cr_pcurve.clone())];
676    let mut repaired = replace_in_face(&mut result, first, &cr_pieces, [uv1_new.x, v1_new])?;
677    let uv2_new = rows.cs_pcurve.evaluate(u_start).or_refuse(KernelStage::Refine, "evaluate")?;
678    let v2_new = if second_split.is_some() {
679        second_crossing_v
680    } else {
681        uv2_new.y
682    };
683    repaired |= replace_in_face(&mut result, second, &cs_pieces, [uv2_new.x, v2_new])?;
684
685    // A contact circle about an axis oblique to a sphere's stored polar axis
686    // can wind once around that sphere's U period.  Such a circle is not an
687    // ordinary hole in the full-domain sphere rectangle: it bounds a cap with
688    // one of the collapsed pole rims.  Keep that pole as the companion loop so
689    // containment, integration, and tessellation see the actual cap topology.
690    let collapse_winding_sphere_cap =
691        |result: &mut BrepSolid, face_id: u64, support_ids: &[u64]| -> Result<(), KernelRefusal> {
692            let face = result
693                .shells
694                .iter_mut()
695                .flat_map(|shell| &mut shell.faces)
696                .find(|face| face.id == face_id)
697                .ok_or(KernelRefusal::internal(KernelStage::Sew, "sphere_carrier", "blend: sphere carrier lost during cap surgery"))?;
698            if !matches!(
699                face.surface.analytic(),
700                Some(crate::AnalyticSurface::Sphere { .. })
701            ) || face.surface.closed_directions().or_refuse(KernelStage::Refine, "closed_directions")? != (true, false)
702            {
703                return Ok(());
704            }
705            let [u0, u1] = face.surface.domain_u().or_refuse(KernelStage::Refine, "domain_u")?;
706            let [v0, v1] = face.surface.domain_v().or_refuse(KernelStage::Refine, "domain_v")?;
707            let period = u1 - u0;
708            let support_loop = face.loops.iter().position(|loop_record| {
709                !loop_record.coedges.is_empty()
710                    && loop_record
711                        .coedges
712                        .iter()
713                        .all(|coedge| support_ids.contains(&coedge.edge_id))
714            });
715            let Some(support_loop) = support_loop else {
716                return Ok(());
717            };
718            if face.loops[support_loop].coedges.len() != 1 {
719                return Ok(());
720            }
721            let support = &face.loops[support_loop].coedges[0];
722            let [s0, s1] = support.pcurve.domain().or_refuse(KernelStage::Refine, "domain")?;
723            let support_start = support.pcurve.evaluate(s0).or_refuse(KernelStage::Refine, "evaluate")?;
724            let support_end = support.pcurve.evaluate(s1).or_refuse(KernelStage::Refine, "evaluate")?;
725            let support_winding = support_end.x - support_start.x;
726            if (support_winding.abs() - period).abs() > 0.05 * period {
727                return Ok(());
728            }
729
730            let mut pole: Option<(usize, CoedgeRecord)> = None;
731            for (loop_index, loop_record) in face.loops.iter().enumerate() {
732                if loop_index == support_loop {
733                    continue;
734                }
735                for coedge in &loop_record.coedges {
736                    let [p0, p1] = coedge.pcurve.domain().or_refuse(KernelStage::Refine, "domain")?;
737                    let start = coedge.pcurve.evaluate(p0).or_refuse(KernelStage::Refine, "evaluate")?;
738                    let end = coedge.pcurve.evaluate(p1).or_refuse(KernelStage::Refine, "evaluate")?;
739                    let winding = end.x - start.x;
740                    let at_pole = (start.y - v0).abs() <= 1e-8 || (start.y - v1).abs() <= 1e-8;
741                    if at_pole
742                        && (end.y - start.y).abs() <= 1e-8
743                        && (winding.abs() - period).abs() <= 0.05 * period
744                        && winding * support_winding < 0.0
745                    {
746                        pole = Some((loop_index, coedge.clone()));
747                        break;
748                    }
749                }
750                if pole.is_some() {
751                    break;
752                }
753            }
754            let Some((pole_loop, pole_coedge)) = pole else {
755                return Ok(());
756            };
757            let support_record = face.loops[support_loop].clone();
758            let pole_id = face.loops[pole_loop].id;
759            face.loops = vec![
760                LoopRecord {
761                    id: pole_id,
762                    coedges: vec![pole_coedge],
763                },
764                support_record,
765            ];
766            Ok(())
767        };
768    collapse_winding_sphere_cap(&mut result, first.face.id, &[cr_edge_id])?;
769    let cs_edge_ids: Vec<u64> = cs_pieces.iter().map(|(edge_id, ..)| *edge_id).collect();
770    collapse_winding_sphere_cap(&mut result, second.face.id, &cs_edge_ids)?;
771    // Patch the zero coedge ids introduced for extra pieces.
772    for shell in &mut result.shells {
773        for face in &mut shell.faces {
774            for loop_record in &mut face.loops {
775                for coedge in &mut loop_record.coedges {
776                    if coedge.id == 0 {
777                        coedge.id = take_id();
778                    }
779                }
780            }
781        }
782    }
783
784    // Trim seam EDGES that ended on the removed vertex: their endpoint
785    // moves onto the support-curve crossing on THEIR carrier (first
786    // mate's seam -> the blend seam vertex; second mate's -> its own
787    // seam-crossing vertex).
788    let new_edge_ids: Vec<u64> = std::iter::once(cr_edge_id)
789        .chain(cs_pieces.iter().map(|(edge_id, ..)| *edge_id))
790        .chain(std::iter::once(blend_seam_id))
791        .collect();
792    let first_face_edges: Vec<u64> = first
793        .face
794        .loops
795        .iter()
796        .flat_map(|loop_record| loop_record.coedges.iter().map(|coedge| coedge.edge_id))
797        .collect();
798    for seam_edge in result.edges.iter_mut() {
799        if new_edge_ids.contains(&seam_edge.id) {
800            continue;
801        }
802        if seam_edge.start_vertex_id != old_vertex && seam_edge.end_vertex_id != old_vertex {
803            continue;
804        }
805        let (target_vertex, target_v, target_point) = if first_face_edges.contains(&seam_edge.id) {
806            (vertex1_id, v1_new, cr_start)
807        } else {
808            (second_seam_vertex, v2_new, second_seam_point)
809        };
810        let [c0, c1] = seam_edge.curve.domain().or_refuse(KernelStage::Refine, "domain")?;
811        let clamped = target_v.clamp(c0.min(c1), c0.max(c1));
812        // Using the support crossing's V as the neighbour's CURVE parameter is
813        // only valid when that neighbour is the carrier's seam MERIDIAN, whose
814        // 3D curve is parameterized by v.  Verify it against the vertex the
815        // endpoint is moving to; a neighbour running along U instead (the
816        // carrier's v-seam, bordered by a collar edge that lies on one whole
817        // meridian) lands nowhere near it — the 2026-09-01 report trimmed the
818        // torus' equator seam to u = 0, 10.885 away from its own vertex, and
819        // the solid failed validation with "curve start does not match vertex".
820        let band = 1e-6 * (1.0 + target_point.length());
821        let split_parameter = if seam_edge.id == edge.id {
822            // The BLENDED edge itself still carries the removed vertex at this
823            // point.  Its coedges have already been replaced in both mates, so
824            // the used-edge sweep below discards it; leave its (meaningless)
825            // trim exactly as it was rather than asking a doomed edge to pass
826            // through the support start.
827            clamped
828        } else {
829            match seam_edge.curve.evaluate(clamped) {
830                Ok(point) if point.sub(target_point).length() <= band => clamped,
831                _ => {
832                    repaired = true;
833                    let projection =
834                        crate::project_point_to_curve(&seam_edge.curve, target_point).or_refuse(KernelStage::Refine, "project_point_to_curve")?;
835                    if projection.distance > band {
836                        return Err(KernelRefusal::unsupported(KernelStage::Sew, "neighbour_off_support", format!(
837                            "blend: neighbour edge {} does not pass through the blend's support \
838                             start (off by {:.9}); the trim has no parameter to move to",
839                            seam_edge.id, projection.distance
840                        )));
841                    }
842                    projection.u.clamp(c0.min(c1), c0.max(c1))
843                }
844            }
845        };
846        if seam_edge.start_vertex_id == old_vertex {
847            seam_edge.t0 = split_parameter;
848            seam_edge.start_vertex_id = target_vertex;
849        }
850        if seam_edge.end_vertex_id == old_vertex {
851            seam_edge.t1 = split_parameter;
852            seam_edge.end_vertex_id = target_vertex;
853        }
854    }
855
856    // Blend face: outward orientation matches F1's outward at the v=0 rim.
857    let mid_u = (u_start + u_end) * 0.5;
858    let blend_normal = raw_normal(&rows.surface, mid_u, 0.0)?;
859    let station_uv = rows.cr_pcurve.evaluate(mid_u).or_refuse(KernelStage::Refine, "evaluate")?;
860    let n1 = raw_normal(&first.face.surface, station_uv.x, station_uv.y)?;
861    let out1 = if first.face.same_sense {
862        n1
863    } else {
864        n1.scale(-1.0)
865    };
866    let same_sense = blend_normal.dot(out1) >= 0.0;
867    let loop_id = take_id();
868    let mut coedges = vec![
869        CoedgeRecord {
870            id: take_id(),
871            edge_id: cr_edge_id,
872            forward: true,
873            pcurve: crate::sweep_topology::parameter_line(u_start, 0.0, u_end, 0.0).or_refuse(KernelStage::Refine, "parameter_line")?,
874        },
875        CoedgeRecord {
876            id: take_id(),
877            edge_id: blend_seam_id,
878            forward: true,
879            pcurve: crate::sweep_topology::parameter_line(u_end, 0.0, u_end, 1.0).or_refuse(KernelStage::Refine, "parameter_line")?,
880        },
881    ];
882    for (edge_id, window, _) in cs_pieces.iter().rev() {
883        coedges.push(CoedgeRecord {
884            id: take_id(),
885            edge_id: *edge_id,
886            forward: false,
887            pcurve: crate::sweep_topology::parameter_line(window[1], 1.0, window[0], 1.0).or_refuse(KernelStage::Refine, "parameter_line")?,
888        });
889    }
890    coedges.push(CoedgeRecord {
891        id: take_id(),
892        edge_id: blend_seam_id,
893        forward: false,
894        pcurve: crate::sweep_topology::parameter_line(u_start, 1.0, u_start, 0.0).or_refuse(KernelStage::Refine, "parameter_line")?,
895    });
896    // The coedges above trace the parameter rectangle counter-clockwise in
897    // (u, v); that traversal is only the outward boundary when the blend
898    // surface normal already points outward (same_sense).  When it does not
899    // (a mate whose support rim runs the other way — e.g. the reversed cap
900    // of a seam carrier), the loop must wind the OTHER way so its coedges
901    // traverse the shared support edges opposite to the mate, and so the
902    // classification tangent test (parameter_point_in_face) stays consistent
903    // with same_sense.  Reversing the coedge order and each coedge (forward
904    // flag + pcurve) flips the winding without touching same_sense.
905    if !same_sense {
906        coedges.reverse();
907        for coedge in &mut coedges {
908            coedge.forward = !coedge.forward;
909            coedge.pcurve = coedge.pcurve.reversed().or_refuse(KernelStage::Refine, "reversed")?;
910        }
911    }
912    let blend_face = FaceRecord {
913        id: take_id(),
914        surface: rows.surface,
915        same_sense,
916        loops: vec![LoopRecord {
917            id: loop_id,
918            coedges,
919        }],
920        name: name.map(|value| value.to_string()),
921    };
922    let shell_index = result
923        .shells
924        .iter()
925        .position(|shell| shell.faces.iter().any(|face| face.id == first.face.id))
926        .ok_or(KernelRefusal::internal(KernelStage::Sew, "mate_shell", "blend: mate shell lost during surgery"))?;
927    result.shells[shell_index].faces.push(blend_face);
928
929    let used_edges: std::collections::HashSet<u64> = result
930        .shells
931        .iter()
932        .flat_map(|shell| &shell.faces)
933        .flat_map(|face| &face.loops)
934        .flat_map(|loop_record| &loop_record.coedges)
935        .map(|coedge| coedge.edge_id)
936        .collect();
937    result
938        .edges
939        .retain(|candidate| used_edges.contains(&candidate.id));
940    let used_vertices: std::collections::HashSet<u64> = result
941        .edges
942        .iter()
943        .flat_map(|candidate| [candidate.start_vertex_id, candidate.end_vertex_id])
944        .collect();
945    result
946        .vertices
947        .retain(|candidate| used_vertices.contains(&candidate.id));
948    // A surgery that had to REPAIR a neighbour trim took a branch no existing
949    // fixture exercises, so it does not get the benefit of the doubt: prove the
950    // result before returning it.  Paths that never repaired are untouched (the
951    // check does not run), so this cannot slow or change anything that already
952    // works — and the new branch can never ship a plausible-looking wrong solid
953    // the way the un-repaired trim did.
954    if repaired {
955        let problems = result.validate();
956        if !problems.is_empty() {
957            return Err(KernelRefusal::internal(KernelStage::Validate, "neighbour_trim_closure", format!(
958                "blend: the repaired neighbour trim did not close ({} issue(s), first: {})",
959                problems.len(),
960                problems
961                    .first()
962                    .map(|issue| issue.message.as_str())
963                    .unwrap_or("unknown")
964            )));
965        }
966    }
967    Ok(result)
968}