Skip to main content

brep_kernel/blending/blend/edge/
open.rs

1use super::*;
2
3/// How one support-curve crossing at an end corner resolves during surgery.
4struct SideCross {
5    /// The rim vertex: an existing vertex when the boundary is consumed
6    /// (`consumed == true`), else 0 — a fresh vertex is created during resolve.
7    vertex: u64,
8    /// True when a prior fillet already consumed this whole boundary edge (the
9    /// crossing landed at the boundary's far endpoint), so it is deleted and
10    /// the transverse curve takes over its role on the prior blend face.
11    consumed: bool,
12}
13
14/// General OPEN-edge rolling-ball fillet or chamfer: §4.9 march +
15/// §6.9 surgery with transverse edges on the two end faces.
16pub fn blend_open_edge(
17    solid: &BrepSolid,
18    edge_id: u64,
19    radius: f64,
20    chamfer: bool,
21    name: Option<&str>,
22) -> Result<BrepSolid, String> {
23    if !(radius > 0.0) || !radius.is_finite() {
24        return Err("blend: radius must be positive".into());
25    }
26    blend_open_edge_impl(solid, edge_id, &|_| radius, chamfer, name)
27}
28
29pub(super) fn blend_open_edge_impl(
30    solid: &BrepSolid,
31    edge_id: u64,
32    radius_at: &dyn Fn(f64) -> f64,
33    chamfer: bool,
34    name: Option<&str>,
35) -> Result<BrepSolid, String> {
36    let edge = solid
37        .edges
38        .iter()
39        .find(|edge| edge.id == edge_id)
40        .ok_or_else(|| format!("blend: edge {edge_id} not found"))?;
41    if edge.start_vertex_id == edge.end_vertex_id {
42        return Err("blend: blend_open_edge requires an open edge".into());
43    }
44    let (first_face, first_loop, first_coedge) = locate_mate(solid, edge_id, None)?;
45    let (second_face, second_loop, second_coedge) =
46        locate_mate(solid, edge_id, Some((first_face.id, first_loop)))?;
47    let mid_radius = radius_at(edge.t0 + (edge.t1 - edge.t0) * 0.5);
48    let (rho1, rho2) = signed_radii(
49        edge,
50        first_face,
51        first_coedge,
52        second_face,
53        second_coedge,
54        mid_radius,
55    )?;
56    let first_mate = BlendMate {
57        face: first_face,
58        coedge: first_coedge,
59        loop_index: first_loop,
60        rho: rho1,
61    };
62    let second_mate = BlendMate {
63        face: second_face,
64        coedge: second_coedge,
65        loop_index: second_loop,
66        rho: rho2,
67    };
68    // End topology: boundary edges of both mates at each end vertex, the
69    // single face across the corner, and the support crossings.  When a
70    // prior fillet has already consumed one of the end corners, the support
71    // crossing can land at the very rim of the marched rows (the prior
72    // blend's transverse arc meets our contact line right at its base).
73    // March with a growing overshoot until every crossing lands strictly
74    // inside the fitted-row domain so the surgery can trim cleanly.
75    let debug = std::env::var("BREP_DEBUG_BLEND_MARCH").is_ok();
76    if debug {
77        let sp = edge.curve.evaluate(edge.t0);
78        let ep = edge.curve.evaluate(edge.t1);
79        eprintln!(
80            "OPEN edge {} v{}->v{} first_face {} second_face {} p0={:?} p1={:?}",
81            edge.id,
82            edge.start_vertex_id,
83            edge.end_vertex_id,
84            first_face.id,
85            second_face.id,
86            sp,
87            ep
88        );
89    }
90    let compute = |overshoot_fraction: f64| -> Result<(FittedRows, Vec<EndSurgery>, bool), String> {
91        let stations = march_open_stations(
92            edge,
93            &first_mate,
94            &second_mate,
95            radius_at,
96            overshoot_fraction,
97        )?;
98        let parameters = station_parameters(&stations);
99        let rows = fit_open_rows(&stations, &parameters, chamfer)?;
100        let mut ends = Vec::with_capacity(2);
101        let mut in_range = true;
102        for (vertex, at_start) in [(edge.start_vertex_id, true), (edge.end_vertex_id, false)] {
103            let first_edge_id = boundary_edge_at_vertex(solid, first_face, vertex, edge_id)?;
104            let second_edge_id = boundary_edge_at_vertex(solid, second_face, vertex, edge_id)?;
105            if first_edge_id == second_edge_id {
106                return Err("blend: mates share their end boundary edge (unsupported)".into());
107            }
108            let end_face = end_face_id(
109                solid,
110                first_face.id,
111                second_face.id,
112                first_edge_id,
113                second_edge_id,
114            )?;
115            let first_boundary = solid
116                .edges
117                .iter()
118                .find(|candidate| candidate.id == first_edge_id)
119                .ok_or("blend: end boundary edge missing")?;
120            let second_boundary = solid
121                .edges
122                .iter()
123                .find(|candidate| candidate.id == second_edge_id)
124                .ok_or("blend: end boundary edge missing")?;
125            let first_boundary_coedge = first_face
126                .loops
127                .iter()
128                .flat_map(|loop_record| &loop_record.coedges)
129                .find(|coedge| coedge.edge_id == first_edge_id)
130                .ok_or("blend: mate does not use its end boundary edge")?;
131            let second_boundary_coedge = second_face
132                .loops
133                .iter()
134                .flat_map(|loop_record| &loop_record.coedges)
135                .find(|coedge| coedge.edge_id == second_edge_id)
136                .ok_or("blend: mate does not use its end boundary edge")?;
137            let (cr_parameter, first_edge_parameter) =
138                match support_crossing(&rows.cr, first_boundary, at_start) {
139                    Ok((s, t, _)) => (s, t),
140                    Err(_) => support_crossing_uv(
141                        &rows.cr_pcurve,
142                        first_boundary,
143                        first_boundary_coedge,
144                        at_start,
145                    )?,
146                };
147            let (cs_parameter, second_edge_parameter) =
148                match support_crossing(&rows.cs, second_boundary, at_start) {
149                    Ok((s, t, _)) => (s, t),
150                    Err(_) => support_crossing_uv(
151                        &rows.cs_pcurve,
152                        second_boundary,
153                        second_boundary_coedge,
154                        at_start,
155                    )?,
156                };
157            // Crossings must land strictly inside the row domain [0, 1] so
158            // the surgery can split the support rows.  The start end sits
159            // near 0, the finish end near 1.
160            const RIM_MARGIN: f64 = 2.0e-3;
161            let side_in_range = if at_start {
162                cr_parameter >= RIM_MARGIN && cs_parameter >= RIM_MARGIN
163            } else {
164                cr_parameter <= 1.0 - RIM_MARGIN && cs_parameter <= 1.0 - RIM_MARGIN
165            };
166            in_range &= side_in_range;
167            let end_record = solid
168                .shells
169                .iter()
170                .flat_map(|shell| &shell.faces)
171                .find(|face| face.id == end_face)
172                .ok_or("blend: end face missing")?;
173            if debug {
174                eprintln!(
175                    "  [os {overshoot_fraction:.2}] end v{vertex} at_start={at_start} end_face={end_face} first_boundary={first_edge_id}(t={first_edge_parameter:.4}) second_boundary={second_edge_id}(t={second_edge_parameter:.4}) cr={cr_parameter:.4} cs={cs_parameter:.4} in_range={side_in_range}"
176                );
177            }
178            let clamp01 = |value: f64| value.clamp(0.0, 1.0);
179            let (transverse, blend_pcurve, end_pcurve) = transverse_curve(
180                &rows,
181                end_record,
182                clamp01(cr_parameter),
183                clamp01(cs_parameter),
184            )?;
185            ends.push(EndSurgery {
186                cr_parameter,
187                cs_parameter,
188                first_edge_id,
189                first_edge_parameter,
190                second_edge_id,
191                second_edge_parameter,
192                end_face_id: end_face,
193                transverse_curve: transverse,
194                transverse_blend_pcurve: blend_pcurve,
195                transverse_end_pcurve: end_pcurve,
196            });
197        }
198        Ok((rows, ends, in_range))
199    };
200
201    // Retry with a growing overshoot; keep the first attempt whose crossings
202    // are all in range, else fall back to the widest march tried.
203    let mut chosen: Option<(FittedRows, Vec<EndSurgery>)> = None;
204    let mut last_error: Option<String> = None;
205    for &overshoot_fraction in &[0.08f64, 0.16, 0.28, 0.45] {
206        match compute(overshoot_fraction) {
207            Ok((rows, ends, in_range)) => {
208                let fallback = chosen.is_none();
209                if in_range {
210                    chosen = Some((rows, ends));
211                    break;
212                } else if fallback {
213                    chosen = Some((rows, ends));
214                }
215            }
216            Err(error) => last_error = Some(error),
217        }
218    }
219    let (rows, ends) = chosen.ok_or_else(|| {
220        last_error.unwrap_or_else(|| "blend: open march failed at every overshoot".into())
221    })?;
222
223    build_open_surgery(solid, edge, &first_mate, &second_mate, rows, ends, name)
224}
225
226fn build_open_surgery(
227    solid: &BrepSolid,
228    edge: &EdgeRecord,
229    first: &BlendMate,
230    second: &BlendMate,
231    rows: FittedRows,
232    ends: Vec<EndSurgery>,
233    name: Option<&str>,
234) -> Result<BrepSolid, String> {
235    let mut result = solid.clone();
236    let mut next_id = solid
237        .vertices
238        .iter()
239        .map(|vertex| vertex.id)
240        .chain(solid.edges.iter().map(|edge| edge.id))
241        .chain(
242            solid
243                .shells
244                .iter()
245                .flat_map(|shell| &shell.faces)
246                .flat_map(|face| {
247                    face.loops
248                        .iter()
249                        .map(|loop_record| loop_record.id)
250                        .chain(face.loops.iter().flat_map(|loop_record| {
251                            loop_record.coedges.iter().map(|coedge| coedge.id)
252                        }))
253                        .chain(std::iter::once(face.id))
254                }),
255        )
256        .max()
257        .unwrap_or(0)
258        + 1;
259    let mut take_id = || {
260        let id = next_id;
261        next_id += 1;
262        id
263    };
264    let [start_end, finish_end] = match ends.try_into() {
265        Ok(pair) => pair,
266        Err(_) => return Err("blend: open surgery needs exactly two ends".into()),
267    };
268    let start_end: EndSurgery = start_end;
269    let finish_end: EndSurgery = finish_end;
270
271    // Trim the support rows to the crossing window.
272    let trim_row = |row: &NurbsCurve, a: f64, b: f64| -> Result<NurbsCurve, String> {
273        let (_, tail) = row.split(a)?;
274        let (middle, _) = tail.split(b)?;
275        Ok(middle)
276    };
277    let cr = trim_row(&rows.cr, start_end.cr_parameter, finish_end.cr_parameter)?;
278    let cr_pcurve = trim_row(
279        &rows.cr_pcurve,
280        start_end.cr_parameter,
281        finish_end.cr_parameter,
282    )?;
283    let cs = trim_row(&rows.cs, start_end.cs_parameter, finish_end.cs_parameter)?;
284    let cs_pcurve = trim_row(
285        &rows.cs_pcurve,
286        start_end.cs_parameter,
287        finish_end.cs_parameter,
288    )?;
289
290    let cr_domain = cr.domain()?;
291    let cs_domain = cs.domain()?;
292    let old_start = edge.start_vertex_id;
293    let old_finish = edge.end_vertex_id;
294
295    // Classify each of the four support crossings.  A crossing that lands at
296    // the FAR endpoint of the boundary edge it meets (the end away from the
297    // corner) means a prior fillet already consumed that whole boundary: the
298    // new blend reuses the existing vertex there and the boundary edge is
299    // deleted (its role is taken over by the new transverse curve on the
300    // prior blend face).  An interior crossing is the pristine case — a fresh
301    // vertex with the boundary trimmed to it.
302    let classify =
303        |boundary_id: u64, boundary_param: f64, corner: u64| -> Result<SideCross, String> {
304            let boundary = result
305                .edges
306                .iter()
307                .find(|candidate| candidate.id == boundary_id)
308                .ok_or("blend: end boundary edge missing during surgery")?;
309            let span = (boundary.t1 - boundary.t0).abs().max(1e-12);
310            let far_vertex = if boundary.start_vertex_id == corner {
311                Some((boundary.end_vertex_id, boundary.t1))
312            } else if boundary.end_vertex_id == corner {
313                Some((boundary.start_vertex_id, boundary.t0))
314            } else {
315                None
316            };
317            if let Some((far_id, far_t)) = far_vertex {
318                if (boundary_param - far_t).abs() <= span * 0.02 {
319                    // Sanity: the reused vertex must exist.
320                    if !result
321                        .vertices
322                        .iter()
323                        .any(|candidate| candidate.id == far_id)
324                    {
325                        return Err("blend: consumed boundary far vertex missing".into());
326                    }
327                    return Ok(SideCross {
328                        vertex: far_id,
329                        consumed: true,
330                    });
331                }
332            }
333            Ok(SideCross {
334                vertex: 0,
335                consumed: false,
336            })
337        };
338    let start_first = classify(
339        start_end.first_edge_id,
340        start_end.first_edge_parameter,
341        old_start,
342    )?;
343    let start_second = classify(
344        start_end.second_edge_id,
345        start_end.second_edge_parameter,
346        old_start,
347    )?;
348    let finish_first = classify(
349        finish_end.first_edge_id,
350        finish_end.first_edge_parameter,
351        old_finish,
352    )?;
353    let finish_second = classify(
354        finish_end.second_edge_id,
355        finish_end.second_edge_parameter,
356        old_finish,
357    )?;
358
359    // Resolve the four rim vertices: reuse the existing vertex for a consumed
360    // side, else create a fresh vertex at the support-row endpoint.
361    let mut resolve = |cross: &SideCross, point: Result<Vec3, String>| -> Result<u64, String> {
362        if cross.consumed {
363            Ok(cross.vertex)
364        } else {
365            let id = take_id();
366            result.vertices.push(VertexRecord { id, point: point? });
367            Ok(id)
368        }
369    };
370    let w1a = resolve(&start_first, cr.evaluate(cr_domain[0]))?;
371    let w1b = resolve(&finish_first, cr.evaluate(cr_domain[1]))?;
372    let w2a = resolve(&start_second, cs.evaluate(cs_domain[0]))?;
373    let w2b = resolve(&finish_second, cs.evaluate(cs_domain[1]))?;
374
375    let cr_edge_id = take_id();
376    let cs_edge_id = take_id();
377    result.edges.push(EdgeRecord {
378        id: cr_edge_id,
379        curve: cr.clone(),
380        t0: cr_domain[0],
381        t1: cr_domain[1],
382        start_vertex_id: w1a,
383        end_vertex_id: w1b,
384        degenerate: false,
385        name: None,
386    });
387    result.edges.push(EdgeRecord {
388        id: cs_edge_id,
389        curve: cs.clone(),
390        t0: cs_domain[0],
391        t1: cs_domain[1],
392        start_vertex_id: w2a,
393        end_vertex_id: w2b,
394        degenerate: false,
395        name: None,
396    });
397    let transverse_a_id = take_id();
398    let transverse_b_id = take_id();
399    let ta_domain = start_end.transverse_curve.domain()?;
400    let tb_domain = finish_end.transverse_curve.domain()?;
401    result.edges.push(EdgeRecord {
402        id: transverse_a_id,
403        curve: start_end.transverse_curve.clone(),
404        t0: ta_domain[0],
405        t1: ta_domain[1],
406        start_vertex_id: w1a,
407        end_vertex_id: w2a,
408        degenerate: false,
409        name: None,
410    });
411    result.edges.push(EdgeRecord {
412        id: transverse_b_id,
413        curve: finish_end.transverse_curve.clone(),
414        t0: tb_domain[0],
415        t1: tb_domain[1],
416        start_vertex_id: w1b,
417        end_vertex_id: w2b,
418        degenerate: false,
419        name: None,
420    });
421
422    // Replace the blended edge in both mates' loops, remembering the
423    // traversal senses — the blend face's loop direction is forced by
424    // manifold pairing with F1's use of cr.
425    let mut first_use_forward = true;
426    for (mate, new_edge_id, pcurve_forward, remember) in [
427        (first, cr_edge_id, &cr_pcurve, true),
428        (second, cs_edge_id, &cs_pcurve, false),
429    ] {
430        let face = result
431            .shells
432            .iter_mut()
433            .flat_map(|shell| &mut shell.faces)
434            .find(|face| face.id == mate.face.id)
435            .ok_or("blend: mate face lost during surgery")?;
436        let loop_record = &mut face.loops[mate.loop_index];
437        let position = loop_record
438            .coedges
439            .iter()
440            .position(|coedge| coedge.edge_id == edge.id)
441            .ok_or("blend: edge coedge lost during surgery")?;
442        let old_forward = loop_record.coedges[position].forward;
443        if remember {
444            first_use_forward = old_forward;
445        }
446        loop_record.coedges[position] = CoedgeRecord {
447            id: loop_record.coedges[position].id,
448            edge_id: new_edge_id,
449            forward: old_forward,
450            pcurve: if old_forward {
451                pcurve_forward.clone()
452            } else {
453                pcurve_forward.reversed()?
454            },
455        };
456    }
457
458    // Drop consumed boundary edges from the mate loop they share with the
459    // blended edge: the fresh support coedge already begins at the reused far
460    // vertex, so removing the fully-covered boundary keeps the loop closed.
461    let mut consumed_edges: Vec<u64> = Vec::new();
462    for (mate, sides) in [
463        (
464            first,
465            [
466                (&start_first, start_end.first_edge_id),
467                (&finish_first, finish_end.first_edge_id),
468            ],
469        ),
470        (
471            second,
472            [
473                (&start_second, start_end.second_edge_id),
474                (&finish_second, finish_end.second_edge_id),
475            ],
476        ),
477    ] {
478        for (cross, boundary_id) in sides {
479            if !cross.consumed {
480                continue;
481            }
482            let face = result
483                .shells
484                .iter_mut()
485                .flat_map(|shell| &mut shell.faces)
486                .find(|face| face.id == mate.face.id)
487                .ok_or("blend: mate face lost during surgery")?;
488            face.loops[mate.loop_index]
489                .coedges
490                .retain(|coedge| coedge.edge_id != boundary_id);
491            if !consumed_edges.contains(&boundary_id) {
492                consumed_edges.push(boundary_id);
493            }
494        }
495    }
496
497    // Locate the corner junction on each end face BEFORE trimming (the two
498    // boundary coedges that meet at the old corner vertex) — edge ids alone
499    // are ambiguous on two-coedge cap loops, so the meeting must be at the
500    // corner.
501    let traversal_vertices = |result: &BrepSolid, coedge: &CoedgeRecord| -> Option<(u64, u64)> {
502        let edge = result
503            .edges
504            .iter()
505            .find(|candidate| candidate.id == coedge.edge_id)?;
506        Some(if coedge.forward {
507            (edge.start_vertex_id, edge.end_vertex_id)
508        } else {
509            (edge.end_vertex_id, edge.start_vertex_id)
510        })
511    };
512    // (end, transverse_id, corner, first_consumed, second_consumed) -> plan
513    let mut end_plans = Vec::with_capacity(2);
514    for (end, transverse_id, corner, first_consumed, second_consumed) in [
515        (
516            &start_end,
517            transverse_a_id,
518            old_start,
519            start_first.consumed,
520            start_second.consumed,
521        ),
522        (
523            &finish_end,
524            transverse_b_id,
525            old_finish,
526            finish_first.consumed,
527            finish_second.consumed,
528        ),
529    ] {
530        let face = result
531            .shells
532            .iter()
533            .flat_map(|shell| &shell.faces)
534            .find(|face| face.id == end.end_face_id)
535            .ok_or("blend: end face lost during surgery")?;
536        let mut located = None;
537        'search: for (loop_index, loop_record) in face.loops.iter().enumerate() {
538            let count = loop_record.coedges.len();
539            for index in 0..count {
540                let this = &loop_record.coedges[index];
541                let next = &loop_record.coedges[(index + 1) % count];
542                let this_pair = (this.edge_id, next.edge_id);
543                let matches_pair = this_pair == (end.first_edge_id, end.second_edge_id)
544                    || this_pair == (end.second_edge_id, end.first_edge_id);
545                if !matches_pair {
546                    continue;
547                }
548                let Some((_, this_end)) = traversal_vertices(&result, this) else {
549                    continue;
550                };
551                let Some((next_start, _)) = traversal_vertices(&result, next) else {
552                    continue;
553                };
554                if this_end == corner && next_start == corner {
555                    // Key on stable coedge ids (not indices) so processing one
556                    // end can't invalidate another that shares this face.
557                    located = Some((
558                        loop_index,
559                        this.id,
560                        next.id,
561                        this.edge_id == end.first_edge_id,
562                    ));
563                    break 'search;
564                }
565            }
566        }
567        let Some((loop_index, x_coedge_id, y_coedge_id, x_is_first)) = located else {
568            return Err("blend: end-face corner (adjacent boundary coedges) not found".into());
569        };
570        end_plans.push((
571            end.end_face_id,
572            transverse_id,
573            end.transverse_end_pcurve.clone(),
574            loop_index,
575            x_coedge_id,
576            y_coedge_id,
577            x_is_first,
578            first_consumed,
579            second_consumed,
580        ));
581    }
582
583    // Trim the boundary edges at their support crossings (consumed sides are
584    // deleted instead of trimmed).
585    for (cross, boundary_id, boundary_param, corner, rim) in [
586        (
587            &start_first,
588            start_end.first_edge_id,
589            start_end.first_edge_parameter,
590            old_start,
591            w1a,
592        ),
593        (
594            &start_second,
595            start_end.second_edge_id,
596            start_end.second_edge_parameter,
597            old_start,
598            w2a,
599        ),
600        (
601            &finish_first,
602            finish_end.first_edge_id,
603            finish_end.first_edge_parameter,
604            old_finish,
605            w1b,
606        ),
607        (
608            &finish_second,
609            finish_end.second_edge_id,
610            finish_end.second_edge_parameter,
611            old_finish,
612            w2b,
613        ),
614    ] {
615        if cross.consumed {
616            continue;
617        }
618        trim_edge_at(&mut result, boundary_id, boundary_param, corner, rim)?;
619    }
620
621    // Rebuild each end face's corner: drop the consumed boundary coedge(s) and
622    // splice in the transverse coedge between the (trimmed) boundaries.
623    for (
624        end_face,
625        transverse_id,
626        transverse_end_pcurve,
627        loop_index,
628        x_coedge_id,
629        y_coedge_id,
630        x_is_first,
631        first_consumed,
632        second_consumed,
633    ) in end_plans
634    {
635        let forward = x_is_first;
636        let pcurve = if forward {
637            transverse_end_pcurve
638        } else {
639            transverse_end_pcurve.reversed()?
640        };
641        let x_consumed = if x_is_first {
642            first_consumed
643        } else {
644            second_consumed
645        };
646        let y_consumed = if x_is_first {
647            second_consumed
648        } else {
649            first_consumed
650        };
651        let coedge_id = take_id();
652        let mut transverse_coedge = Some(CoedgeRecord {
653            id: coedge_id,
654            edge_id: transverse_id,
655            forward,
656            pcurve,
657        });
658        let face = result
659            .shells
660            .iter_mut()
661            .flat_map(|shell| &mut shell.faces)
662            .find(|face| face.id == end_face)
663            .ok_or("blend: end face lost during surgery")?;
664        let loop_record = &mut face.loops[loop_index];
665        let count = loop_record.coedges.len();
666        // Re-find the corner pair by stable coedge id (indices can shift when a
667        // sibling end shares this face).
668        let seg_index = loop_record
669            .coedges
670            .iter()
671            .position(|coedge| coedge.id == x_coedge_id)
672            .ok_or("blend: end-face corner coedge lost during surgery")?;
673        let y_index = (seg_index + 1) % count;
674        if loop_record.coedges[y_index].id != y_coedge_id {
675            return Err("blend: end-face corner pair no longer adjacent".into());
676        }
677        let mut rebuilt = Vec::with_capacity(count + 1);
678        for index in 0..count {
679            if index == seg_index {
680                if !x_consumed {
681                    rebuilt.push(loop_record.coedges[index].clone());
682                }
683                rebuilt.push(transverse_coedge.take().expect("transverse spliced once"));
684            } else if index == y_index {
685                if !y_consumed {
686                    rebuilt.push(loop_record.coedges[index].clone());
687                }
688            } else {
689                rebuilt.push(loop_record.coedges[index].clone());
690            }
691        }
692        loop_record.coedges = rebuilt;
693    }
694
695    // Blend face: cr fwd -> TB -> cs rev -> TA rev in (t, z) space.
696    let mid_u = (cr_domain[0] + cr_domain[1]) * 0.5;
697    let blend_normal = raw_normal(&rows.surface, mid_u, 0.0)?;
698    let station_uv = cr_pcurve.evaluate(mid_u)?;
699    let n1 = raw_normal(&first.face.surface, station_uv.x, station_uv.y)?;
700    let out1 = if first.face.same_sense {
701        n1
702    } else {
703        n1.scale(-1.0)
704    };
705    let same_sense = blend_normal.dot(out1) >= 0.0;
706    let loop_id = take_id();
707    // Manifold pairing: the blend's use of cr must oppose F1's.
708    let blend_cr_forward = !first_use_forward;
709    let coedges = if blend_cr_forward {
710        vec![
711            CoedgeRecord {
712                id: take_id(),
713                edge_id: cr_edge_id,
714                forward: true,
715                pcurve: crate::sweep_topology::parameter_line(
716                    cr_domain[0],
717                    0.0,
718                    cr_domain[1],
719                    0.0,
720                )?,
721            },
722            CoedgeRecord {
723                id: take_id(),
724                edge_id: transverse_b_id,
725                forward: true,
726                pcurve: finish_end.transverse_blend_pcurve.clone(),
727            },
728            CoedgeRecord {
729                id: take_id(),
730                edge_id: cs_edge_id,
731                forward: false,
732                pcurve: cs_blend_pcurve_reversed(&cs_domain)?,
733            },
734            CoedgeRecord {
735                id: take_id(),
736                edge_id: transverse_a_id,
737                forward: false,
738                pcurve: start_end.transverse_blend_pcurve.reversed()?,
739            },
740        ]
741    } else {
742        vec![
743            CoedgeRecord {
744                id: take_id(),
745                edge_id: cr_edge_id,
746                forward: false,
747                pcurve: crate::sweep_topology::parameter_line(
748                    cr_domain[1],
749                    0.0,
750                    cr_domain[0],
751                    0.0,
752                )?,
753            },
754            CoedgeRecord {
755                id: take_id(),
756                edge_id: transverse_a_id,
757                forward: true,
758                pcurve: start_end.transverse_blend_pcurve.clone(),
759            },
760            CoedgeRecord {
761                id: take_id(),
762                edge_id: cs_edge_id,
763                forward: true,
764                pcurve: crate::sweep_topology::parameter_line(
765                    cs_domain[0],
766                    1.0,
767                    cs_domain[1],
768                    1.0,
769                )?,
770            },
771            CoedgeRecord {
772                id: take_id(),
773                edge_id: transverse_b_id,
774                forward: false,
775                pcurve: finish_end.transverse_blend_pcurve.reversed()?,
776            },
777        ]
778    };
779    let blend_face = FaceRecord {
780        id: take_id(),
781        surface: rows.surface,
782        same_sense,
783        loops: vec![LoopRecord {
784            id: loop_id,
785            coedges,
786        }],
787        name: name.map(|value| value.to_string()),
788    };
789    let shell_index = result
790        .shells
791        .iter()
792        .position(|shell| shell.faces.iter().any(|face| face.id == first.face.id))
793        .ok_or("blend: mate shell lost during surgery")?;
794    result.shells[shell_index].faces.push(blend_face);
795
796    result.edges.retain(|candidate| candidate.id != edge.id);
797    // Delete the boundary edges a prior fillet's corner had left behind that
798    // this blend fully consumed.
799    result
800        .edges
801        .retain(|candidate| !consumed_edges.contains(&candidate.id));
802    // Prune every vertex no longer referenced by any edge (the old corner
803    // vertices and any freed by consumed boundaries).
804    let used: rustc_hash::FxHashSet<u64> = result
805        .edges
806        .iter()
807        .flat_map(|candidate| [candidate.start_vertex_id, candidate.end_vertex_id])
808        .collect();
809    result
810        .vertices
811        .retain(|candidate| used.contains(&candidate.id));
812    Ok(result)
813}
814
815fn cs_blend_pcurve_reversed(cs_domain: &[f64; 2]) -> Result<NurbsCurve, String> {
816    crate::sweep_topology::parameter_line(cs_domain[1], 1.0, cs_domain[0], 1.0)
817}