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    let stations = match march_stations(edge, &first_mate, &second_mate, radius_at, anchor_u) {
160        Ok(stations) => stations,
161        Err(march_error) => {
162            // A non-converging march often means the rolling ball falls off
163            // one face — try the edge-preserving construction (4.9.7).
164            return blend_closed_edge_keep(
165                solid,
166                edge,
167                &first_mate,
168                &second_mate,
169                radius_at(mid_t),
170                rho1,
171                chamfer,
172                name,
173            )
174            .or_else(|_| {
175                blend_closed_edge_keep(
176                    solid,
177                    edge,
178                    &second_mate,
179                    &first_mate,
180                    radius_at(mid_t),
181                    rho2,
182                    chamfer,
183                    name,
184                )
185            })
186            .map_err(|keep_error| {
187                format!("{march_error}; edge-preserving blend also failed: {keep_error}")
188            });
189        }
190    };
191    // Support-out-of-trim detection (4.9.7 trigger): a tangency track that
192    // leaves its face's trimmed region cannot be trimmed there — the far
193    // boundary must be PRESERVED instead.
194    let support_exits = |face: &FaceRecord, second_side: bool| -> bool {
195        stations.iter().step_by(4).any(|station| {
196            let uv = if second_side {
197                station.uv2
198            } else {
199                station.uv1
200            };
201            crate::parameter_point_in_face(face, crate::Vec2 { x: uv[0], y: uv[1] }, 1e-6)
202                .map(|class| class == crate::PolygonClass::Outside)
203                .unwrap_or(true)
204        })
205    };
206    if support_exits(second_face, true) {
207        return blend_closed_edge_keep(
208            solid,
209            edge,
210            &first_mate,
211            &second_mate,
212            radius_at(mid_t),
213            rho1,
214            chamfer,
215            name,
216        );
217    }
218    if support_exits(first_face, false) {
219        return blend_closed_edge_keep(
220            solid,
221            edge,
222            &second_mate,
223            &first_mate,
224            radius_at(mid_t),
225            rho2,
226            chamfer,
227            name,
228        );
229    }
230    if std::env::var("BREP_DEBUG_BLEND_MARCH").is_ok() {
231        for index in [0usize, 1, 2, STATIONS - 1, STATIONS] {
232            let station = &stations[index];
233            eprintln!(
234                "station {index}: uv1=({:.6},{:.6}) uv2=({:.6},{:.6}) p1=({:.6},{:.6},{:.6}) w={:.6}",
235                station.uv1[0], station.uv1[1], station.uv2[0], station.uv2[1],
236                station.p1.x, station.p1.y, station.p1.z, station.weight,
237            );
238        }
239    }
240    let parameters = station_parameters(&stations);
241    let rows = fit_closed_rows(&stations, &parameters, chamfer)?;
242
243    // Both mates seam-structured: the second support crosses ITS carrier's
244    // seam meridian somewhere mid-loop; split cs there so face2's loop can
245    // keep its seam-in-one-loop structure.
246    let second_split = if second_seam {
247        let seam2 = second_face.surface.domain_u()?[0];
248        let period2 = {
249            let [d0, d1] = second_face.surface.domain_u()?;
250            d1 - d0
251        };
252        // Bracket: unwrapped uv2 track crossing seam2 + k·period.
253        let u2_first = stations[0].uv2[0];
254        let u2_last = stations[stations.len() - 1].uv2[0];
255        let direction = (u2_last - u2_first).signum();
256        let mut k = ((u2_first - seam2) / period2).ceil();
257        if direction < 0.0 {
258            k = ((u2_first - seam2) / period2).floor();
259        }
260        let target = seam2 + k * period2;
261        let inside =
262            (target - u2_first) * direction > 1e-6 && (u2_last - target) * direction > 1e-6;
263        // ALIGNED seams (both carriers' meridians meet at the shared
264        // vertex — the natural coaxial construction): the crossing sits
265        // at the march boundary and no mid-loop split is needed; the
266        // blend seam vertex already lies on both meridians.
267        let aligned = (target - u2_first).abs() <= 1e-6
268            || (target - u2_last).abs() <= 1e-6
269            || ((u2_first - seam2) / period2).fract().abs() <= 1e-9;
270        if !inside && aligned {
271            None
272        } else if !inside {
273            return Err("blend: second seam crossing not bracketed by the march".into());
274        } else {
275            // Fit-space crossing parameter on the fitted cs (Newton via the
276            // fitted pcurve so the split lands exactly where the FITTED track
277            // crosses the meridian).
278            let [fit_low, fit_high] = rows.u_domain;
279            let mut p = fit_low
280                + (fit_high - fit_low) * {
281                    // Seed from the bracketing stations.
282                    let mut seed = 0.5;
283                    for pair in 0..stations.len() - 1 {
284                        let a = stations[pair].uv2[0];
285                        let b = stations[pair + 1].uv2[0];
286                        if (target - a) * (target - b) <= 0.0 {
287                            let local = (target - a) / (b - a);
288                            seed = (parameters[pair]
289                                + (parameters[pair + 1] - parameters[pair]) * local)
290                                .clamp(0.0, 1.0);
291                            break;
292                        }
293                    }
294                    seed
295                };
296            for _ in 0..NEWTON_ITERATIONS {
297                let value = rows.cs_pcurve.evaluate(p)?.x - target;
298                if value.abs() <= 1e-12 {
299                    break;
300                }
301                let step = 1e-8;
302                let probed = rows.cs_pcurve.evaluate(p + step)?.x - target;
303                let derivative = (probed - value) / step;
304                if derivative.abs() <= 1e-14 {
305                    return Err("blend: second seam crossing Newton stalled".into());
306                }
307                p -= value / derivative;
308            }
309            let crossing_v = rows.cs_pcurve.evaluate(p)?.y;
310            Some(SecondSeamSplit {
311                fit_parameter: p,
312                crossing_v,
313                period: period2 * direction,
314            })
315        }
316    } else {
317        None
318    };
319
320    build_surgery(
321        solid,
322        edge,
323        &first_mate,
324        &second_mate,
325        rows,
326        second_split,
327        name,
328    )
329}
330
331/// Where the second support crosses ITS seam-structured carrier's seam
332/// meridian (both-seam closed edges).
333struct SecondSeamSplit {
334    fit_parameter: f64,
335    crossing_v: f64,
336    /// Signed carrier period travelled by the unwrapped pcurve track.
337    period: f64,
338}
339
340/// Replace the blended edge in both mating loops, trim the seam edge of an
341/// anchored closed carrier, and insert the blend face.
342fn build_surgery(
343    solid: &BrepSolid,
344    edge: &EdgeRecord,
345    first: &BlendMate,
346    second: &BlendMate,
347    rows: FittedRows,
348    second_split: Option<SecondSeamSplit>,
349    name: Option<&str>,
350) -> Result<BrepSolid, String> {
351    let mut result = solid.clone();
352    let mut next_id = solid
353        .vertices
354        .iter()
355        .map(|vertex| vertex.id)
356        .chain(solid.edges.iter().map(|edge| edge.id))
357        .chain(
358            solid
359                .shells
360                .iter()
361                .flat_map(|shell| &shell.faces)
362                .flat_map(|face| {
363                    face.loops
364                        .iter()
365                        .map(|loop_record| loop_record.id)
366                        .chain(face.loops.iter().flat_map(|loop_record| {
367                            loop_record.coedges.iter().map(|coedge| coedge.id)
368                        }))
369                        .chain(std::iter::once(face.id))
370                }),
371        )
372        .max()
373        .unwrap_or(0)
374        + 1;
375    let mut take_id = || {
376        let id = next_id;
377        next_id += 1;
378        id
379    };
380
381    let [u_start, u_end] = rows.u_domain;
382    let cr_start = rows.cr.evaluate(u_start)?;
383    let cs_start = rows.cs.evaluate(u_start)?;
384    let vertex1_id = take_id();
385    let vertex2_id = take_id();
386    result.vertices.push(VertexRecord {
387        id: vertex1_id,
388        point: cr_start,
389    });
390    result.vertices.push(VertexRecord {
391        id: vertex2_id,
392        point: cs_start,
393    });
394
395    let cr_edge_id = take_id();
396    result.edges.push(EdgeRecord {
397        id: cr_edge_id,
398        curve: rows.cr.clone(),
399        t0: u_start,
400        t1: u_end,
401        start_vertex_id: vertex1_id,
402        end_vertex_id: vertex1_id,
403        degenerate: false,
404        name: None,
405    });
406    // Second support: whole closed edge, or two pieces split where it
407    // crosses ITS carrier's seam meridian (both-seam closed edges).  Each
408    // piece records (edge id, fit-space window, forward pcurve).
409    let mut cs_pieces: Vec<(u64, [f64; 2], NurbsCurve)> = Vec::new();
410    let mut second_seam_vertex = vertex2_id;
411    let mut second_crossing_v = 0.0;
412    if let Some(split) = &second_split {
413        let p = split.fit_parameter;
414        second_crossing_v = split.crossing_v;
415        let (cs_a, cs_b) = rows.cs.split(p)?;
416        let (pc_a, pc_b) = rows.cs_pcurve.split(p)?;
417        // Bring the wrapped second piece back into the carrier's domain.
418        let mut pc_b = pc_b;
419        for point in pc_b.control_points.iter_mut() {
420            point.x -= split.period * point.w;
421        }
422        let w2s = take_id();
423        second_seam_vertex = w2s;
424        result.vertices.push(VertexRecord {
425            id: w2s,
426            point: rows.cs.evaluate(p)?,
427        });
428        let edge_a = take_id();
429        result.edges.push(EdgeRecord {
430            id: edge_a,
431            curve: cs_a,
432            t0: u_start,
433            t1: p,
434            start_vertex_id: vertex2_id,
435            end_vertex_id: w2s,
436            degenerate: false,
437            name: None,
438        });
439        cs_pieces.push((edge_a, [u_start, p], pc_a));
440        let edge_b = take_id();
441        result.edges.push(EdgeRecord {
442            id: edge_b,
443            curve: cs_b,
444            t0: p,
445            t1: u_end,
446            start_vertex_id: w2s,
447            end_vertex_id: vertex2_id,
448            degenerate: false,
449            name: None,
450        });
451        cs_pieces.push((edge_b, [p, u_end], pc_b));
452    } else {
453        let cs_edge_id = take_id();
454        result.edges.push(EdgeRecord {
455            id: cs_edge_id,
456            curve: rows.cs.clone(),
457            t0: u_start,
458            t1: u_end,
459            start_vertex_id: vertex2_id,
460            end_vertex_id: vertex2_id,
461            degenerate: false,
462            name: None,
463        });
464        cs_pieces.push((cs_edge_id, [u_start, u_end], rows.cs_pcurve.clone()));
465    }
466    let seam_curve = rows.surface.iso_curve_u(u_start)?;
467    let [seam_t0, seam_t1] = seam_curve.domain()?;
468    let blend_seam_id = take_id();
469    result.edges.push(EdgeRecord {
470        id: blend_seam_id,
471        curve: seam_curve,
472        t0: seam_t0,
473        t1: seam_t1,
474        start_vertex_id: vertex1_id,
475        end_vertex_id: vertex2_id,
476        degenerate: false,
477        name: None,
478    });
479
480    let old_vertex = edge.start_vertex_id;
481    let mut replace_in_face = |result: &mut BrepSolid,
482                               mate: &BlendMate,
483                               pieces: &[(u64, [f64; 2], NurbsCurve)],
484                               v_new: f64|
485     -> Result<(), String> {
486        let seam_edge_ids: Vec<u64> = result
487            .edges
488            .iter()
489            .filter(|candidate| {
490                candidate.id != edge.id
491                    && (candidate.start_vertex_id == old_vertex
492                        || candidate.end_vertex_id == old_vertex)
493            })
494            .map(|candidate| candidate.id)
495            .collect();
496        let face = result
497            .shells
498            .iter_mut()
499            .flat_map(|shell| &mut shell.faces)
500            .find(|face| face.id == mate.face.id)
501            .ok_or("blend: mate face lost during surgery")?;
502        let loop_record = &mut face.loops[mate.loop_index];
503        let position = loop_record
504            .coedges
505            .iter()
506            .position(|coedge| coedge.edge_id == edge.id)
507            .ok_or("blend: edge coedge lost during surgery")?;
508        let old_forward = loop_record.coedges[position].forward;
509        let old_seam_v = {
510            let pcurve = &loop_record.coedges[position].pcurve;
511            let [d0, _] = pcurve.domain()?;
512            pcurve.evaluate(d0)?.y
513        };
514        // Replacement coedges: pieces run in u order; a reversed loop use
515        // takes them in reverse order, each reversed.
516        let mut replacements = Vec::new();
517        let ordered: Vec<&(u64, [f64; 2], NurbsCurve)> = if old_forward {
518            pieces.iter().collect()
519        } else {
520            pieces.iter().rev().collect()
521        };
522        for (index, (edge_id, _, pcurve)) in ordered.iter().enumerate() {
523            replacements.push(CoedgeRecord {
524                id: if index == 0 {
525                    loop_record.coedges[position].id
526                } else {
527                    0 // patched by the caller with fresh ids
528                },
529                edge_id: *edge_id,
530                forward: old_forward,
531                pcurve: if old_forward {
532                    pcurve.clone()
533                } else {
534                    pcurve.reversed()?
535                },
536            });
537        }
538        loop_record
539            .coedges
540            .splice(position..=position, replacements);
541        if loop_record.coedges.len() > 1 {
542            // Anchored carrier: the loop's other coedges ride the seam
543            // meridian between the old edge and the rest of the trim.
544            // Pull the pcurve endpoint that met the old edge down to the
545            // support crossing.  (The seam EDGE itself is trimmed after
546            // both replacements, outside this borrow.)
547            for coedge in &mut loop_record.coedges {
548                if pieces
549                    .iter()
550                    .any(|(edge_id, ..)| *edge_id == coedge.edge_id)
551                    || !seam_edge_ids.contains(&coedge.edge_id)
552                {
553                    continue;
554                }
555                let controls = &mut coedge.pcurve.control_points;
556                let last_index = controls.len() - 1;
557                // Seam pcurves run along the v direction; the endpoint at
558                // the removed edge's v moves onto the support crossing.
559                let first_v = controls[0].y / controls[0].w;
560                let last_v = controls[last_index].y / controls[last_index].w;
561                let target = if (first_v - old_seam_v).abs() < (last_v - old_seam_v).abs() {
562                    0
563                } else {
564                    last_index
565                };
566                let w = controls[target].w;
567                controls[target].y = v_new * w;
568            }
569        }
570        Ok(())
571    };
572    let v1_new = rows.cr_pcurve.evaluate(u_start)?.y;
573    let cr_pieces = vec![(cr_edge_id, [u_start, u_end], rows.cr_pcurve.clone())];
574    replace_in_face(&mut result, first, &cr_pieces, v1_new)?;
575    let v2_new = if second_split.is_some() {
576        second_crossing_v
577    } else {
578        rows.cs_pcurve.evaluate(u_start)?.y
579    };
580    replace_in_face(&mut result, second, &cs_pieces, v2_new)?;
581    // Patch the zero coedge ids introduced for extra pieces.
582    for shell in &mut result.shells {
583        for face in &mut shell.faces {
584            for loop_record in &mut face.loops {
585                for coedge in &mut loop_record.coedges {
586                    if coedge.id == 0 {
587                        coedge.id = take_id();
588                    }
589                }
590            }
591        }
592    }
593
594    // Trim seam EDGES that ended on the removed vertex: their endpoint
595    // moves onto the support-curve crossing on THEIR carrier (first
596    // mate's seam -> the blend seam vertex; second mate's -> its own
597    // seam-crossing vertex).
598    let new_edge_ids: Vec<u64> = std::iter::once(cr_edge_id)
599        .chain(cs_pieces.iter().map(|(edge_id, ..)| *edge_id))
600        .chain(std::iter::once(blend_seam_id))
601        .collect();
602    let first_face_edges: Vec<u64> = first
603        .face
604        .loops
605        .iter()
606        .flat_map(|loop_record| loop_record.coedges.iter().map(|coedge| coedge.edge_id))
607        .collect();
608    for seam_edge in result.edges.iter_mut() {
609        if new_edge_ids.contains(&seam_edge.id) {
610            continue;
611        }
612        if seam_edge.start_vertex_id != old_vertex && seam_edge.end_vertex_id != old_vertex {
613            continue;
614        }
615        let (target_vertex, target_v) = if first_face_edges.contains(&seam_edge.id) {
616            (vertex1_id, v1_new)
617        } else {
618            (second_seam_vertex, v2_new)
619        };
620        let [c0, c1] = seam_edge.curve.domain()?;
621        let split_parameter = target_v.clamp(c0.min(c1), c0.max(c1));
622        if seam_edge.start_vertex_id == old_vertex {
623            seam_edge.t0 = split_parameter;
624            seam_edge.start_vertex_id = target_vertex;
625        }
626        if seam_edge.end_vertex_id == old_vertex {
627            seam_edge.t1 = split_parameter;
628            seam_edge.end_vertex_id = target_vertex;
629        }
630    }
631
632    // Blend face: outward orientation matches F1's outward at the v=0 rim.
633    let mid_u = (u_start + u_end) * 0.5;
634    let blend_normal = raw_normal(&rows.surface, mid_u, 0.0)?;
635    let station_uv = rows.cr_pcurve.evaluate(mid_u)?;
636    let n1 = raw_normal(&first.face.surface, station_uv.x, station_uv.y)?;
637    let out1 = if first.face.same_sense {
638        n1
639    } else {
640        n1.scale(-1.0)
641    };
642    let same_sense = blend_normal.dot(out1) >= 0.0;
643    let loop_id = take_id();
644    let mut coedges = vec![
645        CoedgeRecord {
646            id: take_id(),
647            edge_id: cr_edge_id,
648            forward: true,
649            pcurve: crate::sweep_topology::parameter_line(u_start, 0.0, u_end, 0.0)?,
650        },
651        CoedgeRecord {
652            id: take_id(),
653            edge_id: blend_seam_id,
654            forward: true,
655            pcurve: crate::sweep_topology::parameter_line(u_end, 0.0, u_end, 1.0)?,
656        },
657    ];
658    for (edge_id, window, _) in cs_pieces.iter().rev() {
659        coedges.push(CoedgeRecord {
660            id: take_id(),
661            edge_id: *edge_id,
662            forward: false,
663            pcurve: crate::sweep_topology::parameter_line(window[1], 1.0, window[0], 1.0)?,
664        });
665    }
666    coedges.push(CoedgeRecord {
667        id: take_id(),
668        edge_id: blend_seam_id,
669        forward: false,
670        pcurve: crate::sweep_topology::parameter_line(u_start, 1.0, u_start, 0.0)?,
671    });
672    // The coedges above trace the parameter rectangle counter-clockwise in
673    // (u, v); that traversal is only the outward boundary when the blend
674    // surface normal already points outward (same_sense).  When it does not
675    // (a mate whose support rim runs the other way — e.g. the reversed cap
676    // of a seam carrier), the loop must wind the OTHER way so its coedges
677    // traverse the shared support edges opposite to the mate, and so the
678    // classification tangent test (parameter_point_in_face) stays consistent
679    // with same_sense.  Reversing the coedge order and each coedge (forward
680    // flag + pcurve) flips the winding without touching same_sense.
681    if !same_sense {
682        coedges.reverse();
683        for coedge in &mut coedges {
684            coedge.forward = !coedge.forward;
685            coedge.pcurve = coedge.pcurve.reversed()?;
686        }
687    }
688    let blend_face = FaceRecord {
689        id: take_id(),
690        surface: rows.surface,
691        same_sense,
692        loops: vec![LoopRecord {
693            id: loop_id,
694            coedges,
695        }],
696        name: name.map(|value| value.to_string()),
697    };
698    let shell_index = result
699        .shells
700        .iter()
701        .position(|shell| shell.faces.iter().any(|face| face.id == first.face.id))
702        .ok_or("blend: mate shell lost during surgery")?;
703    result.shells[shell_index].faces.push(blend_face);
704
705    result.edges.retain(|candidate| candidate.id != edge.id);
706    let old_vertex_still_used = result.edges.iter().any(|candidate| {
707        candidate.start_vertex_id == old_vertex || candidate.end_vertex_id == old_vertex
708    });
709    if !old_vertex_still_used {
710        result
711            .vertices
712            .retain(|candidate| candidate.id != old_vertex);
713    }
714    Ok(result)
715}