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).map_err(
177                |retry_error| {
178                    // A FOLD found by the un-anchored march is the SHAPE's
179                    // answer, and it outranks the anchored march's complaint:
180                    // reporting the anchor's non-convergence instead would send
181                    // a proven-folding centre curve on to the edge-preserving
182                    // construction below.
183                    if crate::blend::is_wall_fold(&retry_error) {
184                        retry_error
185                    } else {
186                        anchored_error
187                    }
188                },
189            )
190        }
191        Err(anchored_error) => Err(anchored_error),
192    };
193    let stations = match marched {
194        Ok(stations) => stations,
195        // A wall that FOLDS is refused here for the same reason an escaped
196        // march is: the edge-preserving construction would build a blend on the
197        // same folding centre curve, and if it converged it would return the
198        // very surface this refusal exists to stop.  It is not the fallback's
199        // question to answer, so the geometry is reported as-is.
200        Err(march_error) if crate::blend::is_wall_fold(&march_error) => return Err(march_error),
201        Err(march_error) if march_error.starts_with(crate::blend::BALL_OFF_CARRIER) => {
202            // An ESCAPED march is not a non-convergence: the tangency system
203            // had no solution at all, so no fallback can build the blend the
204            // rolling ball never made.  Report the escape itself — routing it
205            // to the edge-preserving construction below only reports THAT
206            // lane's complaint about a face it was never meant to consume
207            // ("ambiguous preserved boundary edge" on the 2026-09-10 report).
208            return Err(march_error);
209        }
210        Err(march_error) => {
211            // A non-converging march often means the rolling ball falls off
212            // one face — try the edge-preserving construction (4.9.7).
213            return blend_closed_edge_keep(
214                solid,
215                edge,
216                &first_mate,
217                &second_mate,
218                radius_at(mid_t),
219                rho1,
220                chamfer,
221                name,
222            )
223            .or_else(|_| {
224                blend_closed_edge_keep(
225                    solid,
226                    edge,
227                    &second_mate,
228                    &first_mate,
229                    radius_at(mid_t),
230                    rho2,
231                    chamfer,
232                    name,
233                )
234            })
235            .map_err(|keep_error| {
236                format!("{march_error}; edge-preserving blend also failed: {keep_error}")
237            });
238        }
239    };
240    // Support-out-of-trim detection (4.9.7 trigger): a tangency track that
241    // leaves its face's trimmed region cannot be trimmed there — the far
242    // boundary must be PRESERVED instead.
243    let support_exits = |face: &FaceRecord, second_side: bool| -> bool {
244        // Adaptive marches vary the station count; keep at least the old fixed
245        // grid's ~16-probe floor while inheriting extra density where the
246        // march refined (which is where an exit would hide).
247        let stride = (stations.len() / 16).max(1);
248        stations.iter().step_by(stride).any(|station| {
249            let mut uv = if second_side {
250                station.uv2
251            } else {
252                station.uv1
253            };
254            if let Ok((closed_u, closed_v)) = face.surface.closed_directions() {
255                if closed_u {
256                    if let Ok([low, high]) = face.surface.domain_u() {
257                        uv[0] = low + (uv[0] - low).rem_euclid(high - low);
258                    }
259                }
260                if closed_v {
261                    if let Ok([low, high]) = face.surface.domain_v() {
262                        uv[1] = low + (uv[1] - low).rem_euclid(high - low);
263                    }
264                }
265            }
266            crate::parameter_point_in_face(face, crate::Vec2 { x: uv[0], y: uv[1] }, 1e-6)
267                .map(|class| class == crate::PolygonClass::Outside)
268                .unwrap_or(true)
269        })
270    };
271    if support_exits(second_face, true) {
272        return blend_closed_edge_keep(
273            solid,
274            edge,
275            &first_mate,
276            &second_mate,
277            radius_at(mid_t),
278            rho1,
279            chamfer,
280            name,
281        );
282    }
283    if support_exits(first_face, false) {
284        return blend_closed_edge_keep(
285            solid,
286            edge,
287            &second_mate,
288            &first_mate,
289            radius_at(mid_t),
290            rho2,
291            chamfer,
292            name,
293        );
294    }
295    if std::env::var("BREP_DEBUG_BLEND_MARCH").is_ok() {
296        let last = stations.len() - 1;
297        for index in [
298            0usize,
299            1.min(last),
300            2.min(last),
301            last.saturating_sub(1),
302            last,
303        ] {
304            let station = &stations[index];
305            eprintln!(
306                "station {index}: uv1=({:.6},{:.6}) uv2=({:.6},{:.6}) p1=({:.6},{:.6},{:.6}) w={:.6}",
307                station.uv1[0], station.uv1[1], station.uv2[0], station.uv2[1],
308                station.p1.x, station.p1.y, station.p1.z, station.weight,
309            );
310        }
311    }
312    let parameters = station_parameters(&stations);
313    let rows = match exact_closed_revolution_rows(&stations, &first_mate, &second_mate, chamfer) {
314        Some(rows) => rows?,
315        None => fit_closed_rows(&stations, &parameters, chamfer)?,
316    };
317
318    // Both mates seam-structured: the second support crosses ITS carrier's
319    // seam meridian somewhere mid-loop; split cs there so face2's loop can
320    // keep its seam-in-one-loop structure.
321    let second_split = if second_seam {
322        let seam2 = second_face.surface.domain_u()?[0];
323        let period2 = {
324            let [d0, d1] = second_face.surface.domain_u()?;
325            d1 - d0
326        };
327        // Bracket: unwrapped uv2 track crossing seam2 + k·period.
328        let u2_first = stations[0].uv2[0];
329        let u2_last = stations[stations.len() - 1].uv2[0];
330        let direction = (u2_last - u2_first).signum();
331        let mut k = ((u2_first - seam2) / period2).ceil();
332        if direction < 0.0 {
333            k = ((u2_first - seam2) / period2).floor();
334        }
335        let target = seam2 + k * period2;
336        let inside =
337            (target - u2_first) * direction > 1e-6 && (u2_last - target) * direction > 1e-6;
338        // ALIGNED seams (both carriers' meridians meet at the shared
339        // vertex — the natural coaxial construction): the crossing sits
340        // at the march boundary and no mid-loop split is needed; the
341        // blend seam vertex already lies on both meridians.
342        let aligned = (target - u2_first).abs() <= 1e-6
343            || (target - u2_last).abs() <= 1e-6
344            || ((u2_first - seam2) / period2).fract().abs() <= 1e-9;
345        if !inside && aligned {
346            None
347        } else if !inside {
348            return Err("blend: second seam crossing not bracketed by the march".into());
349        } else {
350            // Fit-space crossing parameter on the fitted cs (Newton via the
351            // fitted pcurve so the split lands exactly where the FITTED track
352            // crosses the meridian).
353            let [fit_low, fit_high] = rows.u_domain;
354            let mut p = fit_low
355                + (fit_high - fit_low) * {
356                    // Seed from the bracketing stations.
357                    let mut seed = 0.5;
358                    for pair in 0..stations.len() - 1 {
359                        let a = stations[pair].uv2[0];
360                        let b = stations[pair + 1].uv2[0];
361                        if (target - a) * (target - b) <= 0.0 {
362                            let local = (target - a) / (b - a);
363                            seed = (parameters[pair]
364                                + (parameters[pair + 1] - parameters[pair]) * local)
365                                .clamp(0.0, 1.0);
366                            break;
367                        }
368                    }
369                    seed
370                };
371            for _ in 0..NEWTON_ITERATIONS {
372                let value = rows.cs_pcurve.evaluate(p)?.x - target;
373                if value.abs() <= 1e-12 {
374                    break;
375                }
376                let step = 1e-8;
377                let probed = rows.cs_pcurve.evaluate(p + step)?.x - target;
378                let derivative = (probed - value) / step;
379                if derivative.abs() <= 1e-14 {
380                    return Err("blend: second seam crossing Newton stalled".into());
381                }
382                p -= value / derivative;
383            }
384            let crossing_v = rows.cs_pcurve.evaluate(p)?.y;
385            Some(SecondSeamSplit {
386                fit_parameter: p,
387                crossing_v,
388                period: period2 * direction,
389            })
390        }
391    } else {
392        None
393    };
394
395    build_surgery(
396        solid,
397        edge,
398        &first_mate,
399        &second_mate,
400        rows,
401        second_split,
402        name,
403    )
404}
405
406/// Where the second support crosses ITS seam-structured carrier's seam
407/// meridian (both-seam closed edges).
408struct SecondSeamSplit {
409    fit_parameter: f64,
410    crossing_v: f64,
411    /// Signed carrier period travelled by the unwrapped pcurve track.
412    period: f64,
413}
414
415/// Replace the blended edge in both mating loops, trim the seam edge of an
416/// anchored closed carrier, and insert the blend face.
417fn build_surgery(
418    solid: &BrepSolid,
419    edge: &EdgeRecord,
420    first: &BlendMate,
421    second: &BlendMate,
422    rows: FittedRows,
423    second_split: Option<SecondSeamSplit>,
424    name: Option<&str>,
425) -> Result<BrepSolid, String> {
426    let mut result = solid.clone();
427    let mut take_id = crate::blend::edge::fresh_id_source(solid);
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}