Skip to main content

brep_kernel/blending/blend/edge/
closed.rs

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