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) if march_error.starts_with(crate::blend::BALL_OFF_CARRIER) => {
184            // An ESCAPED march is not a non-convergence: the tangency system
185            // had no solution at all, so no fallback can build the blend the
186            // rolling ball never made.  Report the escape itself — routing it
187            // to the edge-preserving construction below only reports THAT
188            // lane's complaint about a face it was never meant to consume
189            // ("ambiguous preserved boundary edge" on the 2026-09-10 report).
190            return Err(march_error);
191        }
192        Err(march_error) => {
193            // A non-converging march often means the rolling ball falls off
194            // one face — try the edge-preserving construction (4.9.7).
195            return blend_closed_edge_keep(
196                solid,
197                edge,
198                &first_mate,
199                &second_mate,
200                radius_at(mid_t),
201                rho1,
202                chamfer,
203                name,
204            )
205            .or_else(|_| {
206                blend_closed_edge_keep(
207                    solid,
208                    edge,
209                    &second_mate,
210                    &first_mate,
211                    radius_at(mid_t),
212                    rho2,
213                    chamfer,
214                    name,
215                )
216            })
217            .map_err(|keep_error| {
218                format!("{march_error}; edge-preserving blend also failed: {keep_error}")
219            });
220        }
221    };
222    // Support-out-of-trim detection (4.9.7 trigger): a tangency track that
223    // leaves its face's trimmed region cannot be trimmed there — the far
224    // boundary must be PRESERVED instead.
225    let support_exits = |face: &FaceRecord, second_side: bool| -> bool {
226        // Adaptive marches vary the station count; keep at least the old fixed
227        // grid's ~16-probe floor while inheriting extra density where the
228        // march refined (which is where an exit would hide).
229        let stride = (stations.len() / 16).max(1);
230        stations.iter().step_by(stride).any(|station| {
231            let mut uv = if second_side {
232                station.uv2
233            } else {
234                station.uv1
235            };
236            if let Ok((closed_u, closed_v)) = face.surface.closed_directions() {
237                if closed_u {
238                    if let Ok([low, high]) = face.surface.domain_u() {
239                        uv[0] = low + (uv[0] - low).rem_euclid(high - low);
240                    }
241                }
242                if closed_v {
243                    if let Ok([low, high]) = face.surface.domain_v() {
244                        uv[1] = low + (uv[1] - low).rem_euclid(high - low);
245                    }
246                }
247            }
248            crate::parameter_point_in_face(face, crate::Vec2 { x: uv[0], y: uv[1] }, 1e-6)
249                .map(|class| class == crate::PolygonClass::Outside)
250                .unwrap_or(true)
251        })
252    };
253    if support_exits(second_face, true) {
254        return blend_closed_edge_keep(
255            solid,
256            edge,
257            &first_mate,
258            &second_mate,
259            radius_at(mid_t),
260            rho1,
261            chamfer,
262            name,
263        );
264    }
265    if support_exits(first_face, false) {
266        return blend_closed_edge_keep(
267            solid,
268            edge,
269            &second_mate,
270            &first_mate,
271            radius_at(mid_t),
272            rho2,
273            chamfer,
274            name,
275        );
276    }
277    if std::env::var("BREP_DEBUG_BLEND_MARCH").is_ok() {
278        let last = stations.len() - 1;
279        for index in [
280            0usize,
281            1.min(last),
282            2.min(last),
283            last.saturating_sub(1),
284            last,
285        ] {
286            let station = &stations[index];
287            eprintln!(
288                "station {index}: uv1=({:.6},{:.6}) uv2=({:.6},{:.6}) p1=({:.6},{:.6},{:.6}) w={:.6}",
289                station.uv1[0], station.uv1[1], station.uv2[0], station.uv2[1],
290                station.p1.x, station.p1.y, station.p1.z, station.weight,
291            );
292        }
293    }
294    let parameters = station_parameters(&stations);
295    let rows = match exact_closed_revolution_rows(&stations, &first_mate, &second_mate, chamfer) {
296        Some(rows) => rows?,
297        None => fit_closed_rows(&stations, &parameters, chamfer)?,
298    };
299
300    // Both mates seam-structured: the second support crosses ITS carrier's
301    // seam meridian somewhere mid-loop; split cs there so face2's loop can
302    // keep its seam-in-one-loop structure.
303    let second_split = if second_seam {
304        let seam2 = second_face.surface.domain_u()?[0];
305        let period2 = {
306            let [d0, d1] = second_face.surface.domain_u()?;
307            d1 - d0
308        };
309        // Bracket: unwrapped uv2 track crossing seam2 + k·period.
310        let u2_first = stations[0].uv2[0];
311        let u2_last = stations[stations.len() - 1].uv2[0];
312        let direction = (u2_last - u2_first).signum();
313        let mut k = ((u2_first - seam2) / period2).ceil();
314        if direction < 0.0 {
315            k = ((u2_first - seam2) / period2).floor();
316        }
317        let target = seam2 + k * period2;
318        let inside =
319            (target - u2_first) * direction > 1e-6 && (u2_last - target) * direction > 1e-6;
320        // ALIGNED seams (both carriers' meridians meet at the shared
321        // vertex — the natural coaxial construction): the crossing sits
322        // at the march boundary and no mid-loop split is needed; the
323        // blend seam vertex already lies on both meridians.
324        let aligned = (target - u2_first).abs() <= 1e-6
325            || (target - u2_last).abs() <= 1e-6
326            || ((u2_first - seam2) / period2).fract().abs() <= 1e-9;
327        if !inside && aligned {
328            None
329        } else if !inside {
330            return Err("blend: second seam crossing not bracketed by the march".into());
331        } else {
332            // Fit-space crossing parameter on the fitted cs (Newton via the
333            // fitted pcurve so the split lands exactly where the FITTED track
334            // crosses the meridian).
335            let [fit_low, fit_high] = rows.u_domain;
336            let mut p = fit_low
337                + (fit_high - fit_low) * {
338                    // Seed from the bracketing stations.
339                    let mut seed = 0.5;
340                    for pair in 0..stations.len() - 1 {
341                        let a = stations[pair].uv2[0];
342                        let b = stations[pair + 1].uv2[0];
343                        if (target - a) * (target - b) <= 0.0 {
344                            let local = (target - a) / (b - a);
345                            seed = (parameters[pair]
346                                + (parameters[pair + 1] - parameters[pair]) * local)
347                                .clamp(0.0, 1.0);
348                            break;
349                        }
350                    }
351                    seed
352                };
353            for _ in 0..NEWTON_ITERATIONS {
354                let value = rows.cs_pcurve.evaluate(p)?.x - target;
355                if value.abs() <= 1e-12 {
356                    break;
357                }
358                let step = 1e-8;
359                let probed = rows.cs_pcurve.evaluate(p + step)?.x - target;
360                let derivative = (probed - value) / step;
361                if derivative.abs() <= 1e-14 {
362                    return Err("blend: second seam crossing Newton stalled".into());
363                }
364                p -= value / derivative;
365            }
366            let crossing_v = rows.cs_pcurve.evaluate(p)?.y;
367            Some(SecondSeamSplit {
368                fit_parameter: p,
369                crossing_v,
370                period: period2 * direction,
371            })
372        }
373    } else {
374        None
375    };
376
377    build_surgery(
378        solid,
379        edge,
380        &first_mate,
381        &second_mate,
382        rows,
383        second_split,
384        name,
385    )
386}
387
388/// Where the second support crosses ITS seam-structured carrier's seam
389/// meridian (both-seam closed edges).
390struct SecondSeamSplit {
391    fit_parameter: f64,
392    crossing_v: f64,
393    /// Signed carrier period travelled by the unwrapped pcurve track.
394    period: f64,
395}
396
397/// Replace the blended edge in both mating loops, trim the seam edge of an
398/// anchored closed carrier, and insert the blend face.
399fn build_surgery(
400    solid: &BrepSolid,
401    edge: &EdgeRecord,
402    first: &BlendMate,
403    second: &BlendMate,
404    rows: FittedRows,
405    second_split: Option<SecondSeamSplit>,
406    name: Option<&str>,
407) -> Result<BrepSolid, String> {
408    let mut result = solid.clone();
409    let mut take_id = crate::blend::edge::fresh_id_source(solid);
410
411    let [u_start, u_end] = rows.u_domain;
412    let cr_start = rows.cr.evaluate(u_start)?;
413    let cs_start = rows.cs.evaluate(u_start)?;
414    let vertex1_id = take_id();
415    let vertex2_id = take_id();
416    result.vertices.push(VertexRecord {
417        id: vertex1_id,
418        point: cr_start,
419    });
420    result.vertices.push(VertexRecord {
421        id: vertex2_id,
422        point: cs_start,
423    });
424
425    let cr_edge_id = take_id();
426    result.edges.push(EdgeRecord {
427        id: cr_edge_id,
428        curve: rows.cr.clone(),
429        t0: u_start,
430        t1: u_end,
431        start_vertex_id: vertex1_id,
432        end_vertex_id: vertex1_id,
433        degenerate: false,
434        name: None,
435    });
436    // Second support: whole closed edge, or two pieces split where it
437    // crosses ITS carrier's seam meridian (both-seam closed edges).  Each
438    // piece records (edge id, fit-space window, forward pcurve).
439    let mut cs_pieces: Vec<(u64, [f64; 2], NurbsCurve)> = Vec::new();
440    let mut second_seam_vertex = vertex2_id;
441    let mut second_seam_point = cs_start;
442    let mut second_crossing_v = 0.0;
443    if let Some(split) = &second_split {
444        let p = split.fit_parameter;
445        second_crossing_v = split.crossing_v;
446        let (cs_a, cs_b) = rows.cs.split(p)?;
447        let (pc_a, pc_b) = rows.cs_pcurve.split(p)?;
448        // Bring the wrapped second piece back into the carrier's domain.
449        let mut pc_b = pc_b;
450        for point in pc_b.control_points.iter_mut() {
451            point.x -= split.period * point.w;
452        }
453        let w2s = take_id();
454        second_seam_vertex = w2s;
455        second_seam_point = rows.cs.evaluate(p)?;
456        result.vertices.push(VertexRecord {
457            id: w2s,
458            point: second_seam_point,
459        });
460        let edge_a = take_id();
461        result.edges.push(EdgeRecord {
462            id: edge_a,
463            curve: cs_a,
464            t0: u_start,
465            t1: p,
466            start_vertex_id: vertex2_id,
467            end_vertex_id: w2s,
468            degenerate: false,
469            name: None,
470        });
471        cs_pieces.push((edge_a, [u_start, p], pc_a));
472        let edge_b = take_id();
473        result.edges.push(EdgeRecord {
474            id: edge_b,
475            curve: cs_b,
476            t0: p,
477            t1: u_end,
478            start_vertex_id: w2s,
479            end_vertex_id: vertex2_id,
480            degenerate: false,
481            name: None,
482        });
483        cs_pieces.push((edge_b, [p, u_end], pc_b));
484    } else {
485        let cs_edge_id = take_id();
486        result.edges.push(EdgeRecord {
487            id: cs_edge_id,
488            curve: rows.cs.clone(),
489            t0: u_start,
490            t1: u_end,
491            start_vertex_id: vertex2_id,
492            end_vertex_id: vertex2_id,
493            degenerate: false,
494            name: None,
495        });
496        cs_pieces.push((cs_edge_id, [u_start, u_end], rows.cs_pcurve.clone()));
497    }
498    let seam_curve = rows.surface.iso_curve_u(u_start)?;
499    let [seam_t0, seam_t1] = seam_curve.domain()?;
500    let blend_seam_id = take_id();
501    result.edges.push(EdgeRecord {
502        id: blend_seam_id,
503        curve: seam_curve,
504        t0: seam_t0,
505        t1: seam_t1,
506        start_vertex_id: vertex1_id,
507        end_vertex_id: vertex2_id,
508        degenerate: false,
509        name: None,
510    });
511
512    let old_vertex = edge.start_vertex_id;
513    let replace_in_face = |result: &mut BrepSolid,
514                           mate: &BlendMate,
515                           pieces: &[(u64, [f64; 2], NurbsCurve)],
516                           uv_new: [f64; 2]|
517     -> Result<bool, String> {
518        let v_new = uv_new[1];
519        // (edge id, the START vertex is the removed one, the END vertex is).
520        let seam_edge_ends: Vec<(u64, bool, bool)> = result
521            .edges
522            .iter()
523            .filter(|candidate| {
524                candidate.id != edge.id
525                    && (candidate.start_vertex_id == old_vertex
526                        || candidate.end_vertex_id == old_vertex)
527            })
528            .map(|candidate| {
529                (
530                    candidate.id,
531                    candidate.start_vertex_id == old_vertex,
532                    candidate.end_vertex_id == old_vertex,
533                )
534            })
535            .collect();
536        let seam_edge_ids: Vec<u64> = seam_edge_ends.iter().map(|(id, ..)| *id).collect();
537        let face = result
538            .shells
539            .iter_mut()
540            .flat_map(|shell| &mut shell.faces)
541            .find(|face| face.id == mate.face.id)
542            .ok_or("blend: mate face lost during surgery")?;
543        let loop_record = &mut face.loops[mate.loop_index];
544        let position = loop_record
545            .coedges
546            .iter()
547            .position(|coedge| coedge.edge_id == edge.id)
548            .ok_or("blend: edge coedge lost during surgery")?;
549        let old_forward = loop_record.coedges[position].forward;
550        let old_seam_v = {
551            let pcurve = &loop_record.coedges[position].pcurve;
552            let [d0, _] = pcurve.domain()?;
553            pcurve.evaluate(d0)?.y
554        };
555        // Replacement coedges: pieces run in u order; a reversed loop use
556        // takes them in reverse order, each reversed.
557        let mut replacements = Vec::new();
558        let ordered: Vec<&(u64, [f64; 2], NurbsCurve)> = if old_forward {
559            pieces.iter().collect()
560        } else {
561            pieces.iter().rev().collect()
562        };
563        for (index, (edge_id, _, pcurve)) in ordered.iter().enumerate() {
564            replacements.push(CoedgeRecord {
565                id: if index == 0 {
566                    loop_record.coedges[position].id
567                } else {
568                    0 // patched by the caller with fresh ids
569                },
570                edge_id: *edge_id,
571                forward: old_forward,
572                pcurve: if old_forward {
573                    pcurve.clone()
574                } else {
575                    pcurve.reversed()?
576                },
577            });
578        }
579        loop_record
580            .coedges
581            .splice(position..=position, replacements);
582        let mut repaired = false;
583        if loop_record.coedges.len() > 1 {
584            // Anchored carrier: the loop's other coedges ride the seam
585            // meridian between the old edge and the rest of the trim.
586            // Pull the pcurve endpoint that met the old edge down to the
587            // support crossing.  (The seam EDGE itself is trimmed after
588            // both replacements, outside this borrow.)
589            for coedge in &mut loop_record.coedges {
590                if pieces
591                    .iter()
592                    .any(|(edge_id, ..)| *edge_id == coedge.edge_id)
593                    || !seam_edge_ids.contains(&coedge.edge_id)
594                {
595                    continue;
596                }
597                let controls = &mut coedge.pcurve.control_points;
598                let last_index = controls.len() - 1;
599                let first_u = controls[0].x / controls[0].w;
600                let last_u = controls[last_index].x / controls[last_index].w;
601                let constant_u = (first_u - last_u).abs()
602                    <= 1e-9 * (1.0 + first_u.abs().max(last_u.abs()));
603                if constant_u {
604                    // Seam MERIDIAN pcurve: constant u, runs along the v
605                    // direction; the endpoint at the removed edge's v moves
606                    // onto the support crossing.
607                    let first_v = controls[0].y / controls[0].w;
608                    let last_v = controls[last_index].y / controls[last_index].w;
609                    let target = if (first_v - old_seam_v).abs() < (last_v - old_seam_v).abs() {
610                        0
611                    } else {
612                        last_index
613                    };
614                    let w = controls[target].w;
615                    controls[target].y = v_new * w;
616                    continue;
617                }
618                // The adjacent trim runs along U at constant v — the carrier's
619                // OTHER (v) seam, which is what a closed edge lying on one
620                // whole MERIDIAN borders (an axial-plane collar on a torus).
621                // Its endpoint moves in U, not V, and the v-distance rule
622                // above cannot pick the end at all: both ends share the same
623                // v, so it always lands on `last_index`, which is wrong for
624                // one of the seam PAIR (the v-seam edge appears twice in the
625                // loop, at v = 0 and v = 1).  Take the end from the TOPOLOGY.
626                let Some((_, start_is_old, end_is_old)) = seam_edge_ends
627                    .iter()
628                    .find(|(id, ..)| *id == coedge.edge_id)
629                    .copied()
630                else {
631                    continue;
632                };
633                if start_is_old == end_is_old {
634                    return Err(format!(
635                        "blend: neighbour edge {} meets the blended edge at both ends; \
636                         its trim end is ambiguous",
637                        coedge.edge_id
638                    ));
639                }
640                // `forward` maps the coedge's pcurve domain start onto the
641                // edge's t0 (its start vertex).
642                let target = if start_is_old == coedge.forward {
643                    0
644                } else {
645                    last_index
646                };
647                let w = controls[target].w;
648                controls[target].x = uv_new[0] * w;
649                repaired = true;
650            }
651        }
652        Ok(repaired)
653    };
654    let uv1_new = rows.cr_pcurve.evaluate(u_start)?;
655    let v1_new = uv1_new.y;
656    let cr_pieces = vec![(cr_edge_id, [u_start, u_end], rows.cr_pcurve.clone())];
657    let mut repaired = replace_in_face(&mut result, first, &cr_pieces, [uv1_new.x, v1_new])?;
658    let uv2_new = rows.cs_pcurve.evaluate(u_start)?;
659    let v2_new = if second_split.is_some() {
660        second_crossing_v
661    } else {
662        uv2_new.y
663    };
664    repaired |= replace_in_face(&mut result, second, &cs_pieces, [uv2_new.x, v2_new])?;
665
666    // A contact circle about an axis oblique to a sphere's stored polar axis
667    // can wind once around that sphere's U period.  Such a circle is not an
668    // ordinary hole in the full-domain sphere rectangle: it bounds a cap with
669    // one of the collapsed pole rims.  Keep that pole as the companion loop so
670    // containment, integration, and tessellation see the actual cap topology.
671    let collapse_winding_sphere_cap =
672        |result: &mut BrepSolid, face_id: u64, support_ids: &[u64]| -> Result<(), String> {
673            let face = result
674                .shells
675                .iter_mut()
676                .flat_map(|shell| &mut shell.faces)
677                .find(|face| face.id == face_id)
678                .ok_or("blend: sphere carrier lost during cap surgery")?;
679            if !matches!(
680                face.surface.analytic(),
681                Some(crate::AnalyticSurface::Sphere { .. })
682            ) || face.surface.closed_directions()? != (true, false)
683            {
684                return Ok(());
685            }
686            let [u0, u1] = face.surface.domain_u()?;
687            let [v0, v1] = face.surface.domain_v()?;
688            let period = u1 - u0;
689            let support_loop = face.loops.iter().position(|loop_record| {
690                !loop_record.coedges.is_empty()
691                    && loop_record
692                        .coedges
693                        .iter()
694                        .all(|coedge| support_ids.contains(&coedge.edge_id))
695            });
696            let Some(support_loop) = support_loop else {
697                return Ok(());
698            };
699            if face.loops[support_loop].coedges.len() != 1 {
700                return Ok(());
701            }
702            let support = &face.loops[support_loop].coedges[0];
703            let [s0, s1] = support.pcurve.domain()?;
704            let support_start = support.pcurve.evaluate(s0)?;
705            let support_end = support.pcurve.evaluate(s1)?;
706            let support_winding = support_end.x - support_start.x;
707            if (support_winding.abs() - period).abs() > 0.05 * period {
708                return Ok(());
709            }
710
711            let mut pole: Option<(usize, CoedgeRecord)> = None;
712            for (loop_index, loop_record) in face.loops.iter().enumerate() {
713                if loop_index == support_loop {
714                    continue;
715                }
716                for coedge in &loop_record.coedges {
717                    let [p0, p1] = coedge.pcurve.domain()?;
718                    let start = coedge.pcurve.evaluate(p0)?;
719                    let end = coedge.pcurve.evaluate(p1)?;
720                    let winding = end.x - start.x;
721                    let at_pole = (start.y - v0).abs() <= 1e-8 || (start.y - v1).abs() <= 1e-8;
722                    if at_pole
723                        && (end.y - start.y).abs() <= 1e-8
724                        && (winding.abs() - period).abs() <= 0.05 * period
725                        && winding * support_winding < 0.0
726                    {
727                        pole = Some((loop_index, coedge.clone()));
728                        break;
729                    }
730                }
731                if pole.is_some() {
732                    break;
733                }
734            }
735            let Some((pole_loop, pole_coedge)) = pole else {
736                return Ok(());
737            };
738            let support_record = face.loops[support_loop].clone();
739            let pole_id = face.loops[pole_loop].id;
740            face.loops = vec![
741                LoopRecord {
742                    id: pole_id,
743                    coedges: vec![pole_coedge],
744                },
745                support_record,
746            ];
747            Ok(())
748        };
749    collapse_winding_sphere_cap(&mut result, first.face.id, &[cr_edge_id])?;
750    let cs_edge_ids: Vec<u64> = cs_pieces.iter().map(|(edge_id, ..)| *edge_id).collect();
751    collapse_winding_sphere_cap(&mut result, second.face.id, &cs_edge_ids)?;
752    // Patch the zero coedge ids introduced for extra pieces.
753    for shell in &mut result.shells {
754        for face in &mut shell.faces {
755            for loop_record in &mut face.loops {
756                for coedge in &mut loop_record.coedges {
757                    if coedge.id == 0 {
758                        coedge.id = take_id();
759                    }
760                }
761            }
762        }
763    }
764
765    // Trim seam EDGES that ended on the removed vertex: their endpoint
766    // moves onto the support-curve crossing on THEIR carrier (first
767    // mate's seam -> the blend seam vertex; second mate's -> its own
768    // seam-crossing vertex).
769    let new_edge_ids: Vec<u64> = std::iter::once(cr_edge_id)
770        .chain(cs_pieces.iter().map(|(edge_id, ..)| *edge_id))
771        .chain(std::iter::once(blend_seam_id))
772        .collect();
773    let first_face_edges: Vec<u64> = first
774        .face
775        .loops
776        .iter()
777        .flat_map(|loop_record| loop_record.coedges.iter().map(|coedge| coedge.edge_id))
778        .collect();
779    for seam_edge in result.edges.iter_mut() {
780        if new_edge_ids.contains(&seam_edge.id) {
781            continue;
782        }
783        if seam_edge.start_vertex_id != old_vertex && seam_edge.end_vertex_id != old_vertex {
784            continue;
785        }
786        let (target_vertex, target_v, target_point) = if first_face_edges.contains(&seam_edge.id) {
787            (vertex1_id, v1_new, cr_start)
788        } else {
789            (second_seam_vertex, v2_new, second_seam_point)
790        };
791        let [c0, c1] = seam_edge.curve.domain()?;
792        let clamped = target_v.clamp(c0.min(c1), c0.max(c1));
793        // Using the support crossing's V as the neighbour's CURVE parameter is
794        // only valid when that neighbour is the carrier's seam MERIDIAN, whose
795        // 3D curve is parameterized by v.  Verify it against the vertex the
796        // endpoint is moving to; a neighbour running along U instead (the
797        // carrier's v-seam, bordered by a collar edge that lies on one whole
798        // meridian) lands nowhere near it — the 2026-09-01 report trimmed the
799        // torus' equator seam to u = 0, 10.885 away from its own vertex, and
800        // the solid failed validation with "curve start does not match vertex".
801        let band = 1e-6 * (1.0 + target_point.length());
802        let split_parameter = if seam_edge.id == edge.id {
803            // The BLENDED edge itself still carries the removed vertex at this
804            // point.  Its coedges have already been replaced in both mates, so
805            // the used-edge sweep below discards it; leave its (meaningless)
806            // trim exactly as it was rather than asking a doomed edge to pass
807            // through the support start.
808            clamped
809        } else {
810            match seam_edge.curve.evaluate(clamped) {
811                Ok(point) if point.sub(target_point).length() <= band => clamped,
812                _ => {
813                    repaired = true;
814                    let projection =
815                        crate::project_point_to_curve(&seam_edge.curve, target_point)?;
816                    if projection.distance > band {
817                        return Err(format!(
818                            "blend: neighbour edge {} does not pass through the blend's support \
819                             start (off by {:.9}); the trim has no parameter to move to",
820                            seam_edge.id, projection.distance
821                        ));
822                    }
823                    projection.u.clamp(c0.min(c1), c0.max(c1))
824                }
825            }
826        };
827        if seam_edge.start_vertex_id == old_vertex {
828            seam_edge.t0 = split_parameter;
829            seam_edge.start_vertex_id = target_vertex;
830        }
831        if seam_edge.end_vertex_id == old_vertex {
832            seam_edge.t1 = split_parameter;
833            seam_edge.end_vertex_id = target_vertex;
834        }
835    }
836
837    // Blend face: outward orientation matches F1's outward at the v=0 rim.
838    let mid_u = (u_start + u_end) * 0.5;
839    let blend_normal = raw_normal(&rows.surface, mid_u, 0.0)?;
840    let station_uv = rows.cr_pcurve.evaluate(mid_u)?;
841    let n1 = raw_normal(&first.face.surface, station_uv.x, station_uv.y)?;
842    let out1 = if first.face.same_sense {
843        n1
844    } else {
845        n1.scale(-1.0)
846    };
847    let same_sense = blend_normal.dot(out1) >= 0.0;
848    let loop_id = take_id();
849    let mut coedges = vec![
850        CoedgeRecord {
851            id: take_id(),
852            edge_id: cr_edge_id,
853            forward: true,
854            pcurve: crate::sweep_topology::parameter_line(u_start, 0.0, u_end, 0.0)?,
855        },
856        CoedgeRecord {
857            id: take_id(),
858            edge_id: blend_seam_id,
859            forward: true,
860            pcurve: crate::sweep_topology::parameter_line(u_end, 0.0, u_end, 1.0)?,
861        },
862    ];
863    for (edge_id, window, _) in cs_pieces.iter().rev() {
864        coedges.push(CoedgeRecord {
865            id: take_id(),
866            edge_id: *edge_id,
867            forward: false,
868            pcurve: crate::sweep_topology::parameter_line(window[1], 1.0, window[0], 1.0)?,
869        });
870    }
871    coedges.push(CoedgeRecord {
872        id: take_id(),
873        edge_id: blend_seam_id,
874        forward: false,
875        pcurve: crate::sweep_topology::parameter_line(u_start, 1.0, u_start, 0.0)?,
876    });
877    // The coedges above trace the parameter rectangle counter-clockwise in
878    // (u, v); that traversal is only the outward boundary when the blend
879    // surface normal already points outward (same_sense).  When it does not
880    // (a mate whose support rim runs the other way — e.g. the reversed cap
881    // of a seam carrier), the loop must wind the OTHER way so its coedges
882    // traverse the shared support edges opposite to the mate, and so the
883    // classification tangent test (parameter_point_in_face) stays consistent
884    // with same_sense.  Reversing the coedge order and each coedge (forward
885    // flag + pcurve) flips the winding without touching same_sense.
886    if !same_sense {
887        coedges.reverse();
888        for coedge in &mut coedges {
889            coedge.forward = !coedge.forward;
890            coedge.pcurve = coedge.pcurve.reversed()?;
891        }
892    }
893    let blend_face = FaceRecord {
894        id: take_id(),
895        surface: rows.surface,
896        same_sense,
897        loops: vec![LoopRecord {
898            id: loop_id,
899            coedges,
900        }],
901        name: name.map(|value| value.to_string()),
902    };
903    let shell_index = result
904        .shells
905        .iter()
906        .position(|shell| shell.faces.iter().any(|face| face.id == first.face.id))
907        .ok_or("blend: mate shell lost during surgery")?;
908    result.shells[shell_index].faces.push(blend_face);
909
910    let used_edges: std::collections::HashSet<u64> = result
911        .shells
912        .iter()
913        .flat_map(|shell| &shell.faces)
914        .flat_map(|face| &face.loops)
915        .flat_map(|loop_record| &loop_record.coedges)
916        .map(|coedge| coedge.edge_id)
917        .collect();
918    result
919        .edges
920        .retain(|candidate| used_edges.contains(&candidate.id));
921    let used_vertices: std::collections::HashSet<u64> = result
922        .edges
923        .iter()
924        .flat_map(|candidate| [candidate.start_vertex_id, candidate.end_vertex_id])
925        .collect();
926    result
927        .vertices
928        .retain(|candidate| used_vertices.contains(&candidate.id));
929    // A surgery that had to REPAIR a neighbour trim took a branch no existing
930    // fixture exercises, so it does not get the benefit of the doubt: prove the
931    // result before returning it.  Paths that never repaired are untouched (the
932    // check does not run), so this cannot slow or change anything that already
933    // works — and the new branch can never ship a plausible-looking wrong solid
934    // the way the un-repaired trim did.
935    if repaired {
936        let problems = result.validate();
937        if !problems.is_empty() {
938            return Err(format!(
939                "blend: the repaired neighbour trim did not close ({} issue(s), first: {})",
940                problems.len(),
941                problems
942                    .first()
943                    .map(|issue| issue.message.as_str())
944                    .unwrap_or("unknown")
945            ));
946        }
947    }
948    Ok(result)
949}