Skip to main content

brep_kernel/blending/blend/edge/
open.rs

1use crate::{KernelRefusal, KernelStage, OrRefuse};
2use super::*;
3
4/// General OPEN-edge rolling-ball fillet or chamfer: §4.9 march +
5/// §6.9 surgery with transverse edges on the two end faces.
6pub fn blend_open_edge(
7    solid: &BrepSolid,
8    edge_id: u64,
9    radius: f64,
10    chamfer: bool,
11    name: Option<&str>,
12) -> Result<BrepSolid, KernelRefusal> {
13    if !(radius > 0.0) || !radius.is_finite() {
14        return Err(KernelRefusal::input(KernelStage::Collect, "radius", "blend: radius must be positive"));
15    }
16    blend_open_edge_impl(solid, edge_id, &|_| radius, chamfer, name)
17}
18
19pub(super) fn blend_open_edge_impl(
20    solid: &BrepSolid,
21    edge_id: u64,
22    radius_at: &dyn Fn(f64) -> f64,
23    chamfer: bool,
24    name: Option<&str>,
25) -> Result<BrepSolid, KernelRefusal> {
26    let edge = solid
27        .edges
28        .iter()
29        .find(|edge| edge.id == edge_id)
30        .ok_or_else(|| format!("blend: edge {edge_id} not found")).or_refuse(KernelStage::Refine, "ok_or_else")?;
31    if edge.start_vertex_id == edge.end_vertex_id {
32        return Err(KernelRefusal::input(KernelStage::Collect, "open_edge", "blend: blend_open_edge requires an open edge"));
33    }
34    let (first_face, first_loop, first_coedge) = locate_mate(solid, edge_id, None)?;
35    let (second_face, second_loop, second_coedge) =
36        locate_mate(solid, edge_id, Some((first_face.id, first_loop)))?;
37    let mid_radius = radius_at(edge.t0 + (edge.t1 - edge.t0) * 0.5);
38    let (rho1, rho2) = signed_radii(
39        edge,
40        first_face,
41        first_coedge,
42        second_face,
43        second_coedge,
44        mid_radius,
45    )?;
46    let first_mate = BlendMate {
47        face: first_face,
48        coedge: first_coedge,
49        loop_index: first_loop,
50        rho: rho1,
51    };
52    let second_mate = BlendMate {
53        face: second_face,
54        coedge: second_coedge,
55        loop_index: second_loop,
56        rho: rho2,
57    };
58    // End topology: boundary edges of both mates at each end vertex, the
59    // single face across the corner, and the support crossings.  When a
60    // prior fillet has already consumed one of the end corners, the support
61    // crossing can land at the very rim of the marched rows (the prior
62    // blend's transverse arc meets our contact line right at its base).
63    // March with a growing overshoot until every crossing lands strictly
64    // inside the fitted-row domain so the surgery can trim cleanly.
65    let debug = std::env::var("BREP_DEBUG_BLEND_MARCH").is_ok();
66    if debug {
67        let sp = edge.curve.evaluate(edge.t0);
68        let ep = edge.curve.evaluate(edge.t1);
69        eprintln!(
70            "OPEN edge {} v{}->v{} first_face {} second_face {} p0={:?} p1={:?}",
71            edge.id,
72            edge.start_vertex_id,
73            edge.end_vertex_id,
74            first_face.id,
75            second_face.id,
76            sp,
77            ep
78        );
79    }
80    let compute = |overshoot_fraction: f64| -> Result<(FittedRows, Vec<EndSurgery>, bool), KernelRefusal> {
81        // The open-edge surgery locates its ends by support crossings, not by
82        // the marched vertex stations; the snapped indices only break the fit
83        // where the carriers' extension starts (`fit_open_rows`).
84        let (stations, vertex_indices) = march_open_stations(
85            edge,
86            &first_mate,
87            &second_mate,
88            radius_at,
89            overshoot_fraction,
90        )?;
91        let parameters = station_parameters(&stations);
92        let rows = fit_open_rows(
93            &stations,
94            &parameters,
95            chamfer,
96            Some(vertex_indices),
97            extrusion_direction(edge, &first_mate, &second_mate),
98        )?;
99        let mut ends = Vec::with_capacity(2);
100        let mut in_range = true;
101        for (vertex, at_start) in [(edge.start_vertex_id, true), (edge.end_vertex_id, false)] {
102            let (end, side_in_range) = resolve_free_end(
103                solid,
104                edge_id,
105                &first_mate,
106                &second_mate,
107                &rows,
108                vertex,
109                at_start,
110            )?;
111            if debug {
112                eprintln!(
113                    "  [os {overshoot_fraction:.2}] end v{vertex} at_start={at_start} end_face={} first_boundary={}(t={:.4}) second_boundary={}(t={:.4}) cr={:.4} cs={:.4} in_range={side_in_range}",
114                    end.end_face_id,
115                    end.first_edge_id,
116                    end.first_edge_parameter,
117                    end.second_edge_id,
118                    end.second_edge_parameter,
119                    end.cr_parameter,
120                    end.cs_parameter,
121                );
122            }
123            in_range &= side_in_range;
124            ends.push(end);
125        }
126        Ok((rows, ends, in_range))
127    };
128
129    // Radius-aware seeded first attempt (occt-filleting-system-study §6(b)
130    // lesson 7): OCCT floors corner extensions at 1.5·max_radius
131    // (`ExtentTwoCorner`) instead of probing blindly.  Convert that floor to
132    // an overshoot fraction of THIS edge: the contact rails sit r·tan(α/2)
133    // from the edge (α = sign-adjusted angle between the mates' raw normals
134    // at mid-edge — the march's own `cos_alpha`), so seed with 1.5× the
135    // widest end radius's rail offset over the edge arc length, clamped to
136    // the ladder's proven [0.08, 0.45] envelope.  Deterministic; `None` when
137    // it degenerates or merely reproduces the ladder's first rung.
138    let seeded_fraction: Option<f64> = (|| {
139        let span = edge.t1 - edge.t0;
140        let mut length = 0.0f64;
141        let mut previous = edge.curve.evaluate(edge.t0).ok()?;
142        for index in 1..=16 {
143            let t = edge.t0 + span * index as f64 / 16.0;
144            let point = edge.curve.evaluate(t).ok()?;
145            length += point.sub(previous).length();
146            previous = point;
147        }
148        if !(length > 0.0) || !length.is_finite() {
149            return None;
150        }
151        let mid_t = edge.t0 + span * 0.5;
152        let uv1 = edge_uv_on_face(first_mate.coedge, edge, mid_t).ok()?;
153        let uv2 = edge_uv_on_face(second_mate.coedge, edge, mid_t).ok()?;
154        let n1 = raw_normal(&first_mate.face.surface, uv1[0], uv1[1]).ok()?;
155        let n2 = raw_normal(&second_mate.face.surface, uv2[0], uv2[1]).ok()?;
156        let cos_alpha =
157            (rho1.signum() * rho2.signum() * n1.dot(n2)).clamp(-1.0, 1.0);
158        // tan(α/2) = √((1−cosα)/(1+cosα)); the tangent-offset factor from
159        // the edge to each rail (box: α = π/2 → offset = r).
160        let tan_half = ((1.0 - cos_alpha).max(0.0) / (1.0 + cos_alpha).max(1e-9)).sqrt();
161        let end_radius = radius_at(edge.t0).abs().max(radius_at(edge.t1).abs());
162        let fraction = (1.5 * end_radius * tan_half / length).clamp(0.08, 0.45);
163        if !fraction.is_finite() || (fraction - 0.08).abs() < 1e-12 {
164            return None;
165        }
166        Some(fraction)
167    })();
168
169    // Retry with a growing overshoot; keep the first attempt whose crossings
170    // are all in range, else fall back to the widest march tried.  The seeded
171    // attempt is accepted ONLY when its crossings land in range — otherwise
172    // the blind ladder below runs exactly as before (including its
173    // fallback-to-first-Ok semantics), so the seed can improve the first
174    // landing but never change the fallback behaviour.
175    let mut chosen: Option<(FittedRows, Vec<EndSurgery>)> = None;
176    let mut last_error: Option<KernelRefusal> = None;
177    if let Some(fraction) = seeded_fraction {
178        match compute(fraction) {
179            Ok((rows, ends, true)) => chosen = Some((rows, ends)),
180            Ok(_) => {}
181            Err(error) => last_error = Some(error),
182        }
183    }
184    if chosen.is_none() {
185        for &overshoot_fraction in &[0.08f64, 0.16, 0.28, 0.45] {
186            match compute(overshoot_fraction) {
187                Ok((rows, ends, in_range)) => {
188                    let fallback = chosen.is_none();
189                    if in_range {
190                        chosen = Some((rows, ends));
191                        break;
192                    } else if fallback {
193                        chosen = Some((rows, ends));
194                    }
195                }
196                Err(error) => last_error = Some(error),
197            }
198        }
199    }
200    let (rows, ends) = chosen.ok_or_else(|| {
201        last_error.unwrap_or_else(|| {
202            KernelRefusal::internal(
203                KernelStage::Refine,
204                "open_march",
205                "blend: open march failed at every overshoot",
206            )
207        })
208    })?;
209
210    let [start_end, finish_end] = match <[EndSurgery; 2]>::try_from(ends) {
211        Ok(pair) => pair,
212        Err(_) => return Err(KernelRefusal::internal(KernelStage::Sew, "open_surgery_ends", "blend: open surgery needs exactly two ends")),
213    };
214    // A crossing that only resolved on a looser rung of the tolerance ladder
215    // builds something master refused outright, so it must earn its answer
216    // (see [`SpokeCrossings`]).
217    let mut crossings = SpokeCrossings::default();
218    crossings.note(start_end.escalated || finish_end.escalated);
219    let mut result = solid.clone();
220    let mut take_id = fresh_id_source(solid);
221    let sewn = build_open_surgery(
222        solid,
223        &mut result,
224        &mut take_id,
225        edge,
226        &first_mate,
227        &second_mate,
228        rows,
229        [EndPlan::Free(start_end), EndPlan::Free(finish_end)],
230        name,
231    )?;
232    prune_orphan_vertices(&mut result);
233    check_snap_closure(solid, &result, sewn.snap)?;
234    crossings.gate(result)
235}
236
237/// A fresh id allocator seeded past the vertex, edge, face, loop and coedge IDs.
238/// Solid and shell IDs are outside this allocation domain. Shared by
239/// a whole group of stripes so their vertices, edges and coedges cannot
240/// collide.
241pub(in crate::blend) fn fresh_id_source(solid: &BrepSolid) -> impl FnMut() -> u64 {
242    let mut next_id = solid
243        .vertices
244        .iter()
245        .map(|vertex| vertex.id)
246        .chain(solid.edges.iter().map(|edge| edge.id))
247        .chain(
248            solid
249                .shells
250                .iter()
251                .flat_map(|shell| &shell.faces)
252                .flat_map(|face| {
253                    face.loops
254                        .iter()
255                        .map(|loop_record| loop_record.id)
256                        .chain(face.loops.iter().flat_map(|loop_record| {
257                            loop_record.coedges.iter().map(|coedge| coedge.id)
258                        }))
259                        .chain(std::iter::once(face.id))
260                }),
261        )
262        .max()
263        .unwrap_or(0)
264        + 1;
265    move || {
266        let id = next_id;
267        next_id += 1;
268        id
269    }
270}
271
272/// Sew ONE stripe into `result`.
273///
274/// `solid` is the ORIGINAL topology every stripe was marched against (the
275/// row-coincidence detection reads it); `result` is the shared evolving
276/// solid, and `take_id` the shared id allocator — a group of stripes meeting
277/// at a corner must create their rim vertices and cross-section arcs from one
278/// counter, and must see each other's work.
279///
280/// Each end is either free (terminate on the face across the corner) or a
281/// corner stop; see [`EndPlan`].  Pruning orphaned vertices is the caller's
282/// job, once, after every stripe of the group is in.
283pub(in crate::blend) fn build_open_surgery(
284    solid: &BrepSolid,
285    result: &mut BrepSolid,
286    take_id: &mut dyn FnMut() -> u64,
287    edge: &EdgeRecord,
288    first: &BlendMate,
289    second: &BlendMate,
290    rows: FittedRows,
291    ends: [EndPlan; 2],
292    name: Option<&str>,
293) -> Result<SewnStripe, KernelRefusal> {
294    let [start_end, finish_end] = ends;
295
296    // Trim the support rows to the crossing window.
297    // A POLE end stops EXACTLY on the row's own end (the march puts the
298    // pole's station there and overshoots no further), so there is nothing to
299    // split off on that side.  Only equality: a stop PAST the row's end — a
300    // free end's crossing the rows never reached — still fails the split and
301    // refuses, rather than building a rail out to wherever the rows stop.
302    let trim_row = |row: &NurbsCurve, a: f64, b: f64| -> Result<NurbsCurve, KernelRefusal> {
303        let [low, high] = row.domain().or_refuse(KernelStage::Refine, "domain")?;
304        let tail = if a == low { row.clone() } else { row.split(a).or_refuse(KernelStage::Refine, "split")?.1 };
305        if b == high {
306            return Ok(tail);
307        }
308        let (middle, _) = tail.split(b).or_refuse(KernelStage::Refine, "split")?;
309        Ok(middle)
310    };
311    let kind = |plan: &EndPlan| match plan {
312        EndPlan::Free(_) => "free",
313        EndPlan::Corner(_) => "corner",
314        EndPlan::Miter(_) => "miter",
315        EndPlan::Cap(_) => "cap",
316    };
317    let describe_trim = |error: KernelRefusal| {
318        error.with_message(|error| {
319            format!(
320                "{error} (edge {} rows trimmed for a {} start at cr {:.6}/cs {:.6} and a {} finish \
321                 at cr {:.6}/cs {:.6})",
322                edge.id,
323                kind(&start_end),
324                start_end.cr_parameter(),
325                start_end.cs_parameter(),
326                kind(&finish_end),
327                finish_end.cr_parameter(),
328                finish_end.cs_parameter()
329            )
330        })
331    };
332    // A rail whose two stops are one point within `consumed_band` has no strip
333    // to trim: the blend takes that mate down to a single point along this
334    // edge.  That is a FULL-WIDTH corner — the shared face of a miter whose
335    // radius is the side face's width, every face of a star at full width, the
336    // middle edge of a channel whose two corner setbacks meet.  No rail edge is
337    // built; the mate loses the blended edge instead of having it replaced, and
338    // the rail's two rim vertices are one vertex, which the orchestrator
339    // identifies (`SewnStripe::identified`) once every stripe is in.  A stripe
340    // free at BOTH ends has no corner to collapse onto, so it refuses.
341    let band = consumed_band(solid);
342    let collapses = |row: &NurbsCurve, a: f64, b: f64| -> Result<bool, KernelRefusal> {
343        let from = row.evaluate(a).or_refuse(KernelStage::Refine, "evaluate")?;
344        Ok(row.evaluate(b).or_refuse(KernelStage::Refine, "evaluate")?.sub(from).length() <= band
345            && row.evaluate(0.5 * (a + b)).or_refuse(KernelStage::Refine, "evaluate")?.sub(from).length() <= band)
346    };
347    let cr_collapsed = collapses(&rows.cr, start_end.cr_parameter(), finish_end.cr_parameter())?;
348    let cs_collapsed = collapses(&rows.cs, start_end.cs_parameter(), finish_end.cs_parameter())?;
349    if cr_collapsed || cs_collapsed {
350        let unsupported = if start_end.free().is_some() && finish_end.free().is_some() {
351            Some("neither end is a corner")
352        } else if first.face.id == second.face.id {
353            Some("both rails lie on one face")
354        } else if matches!(start_end, EndPlan::Cap(_)) || matches!(finish_end, EndPlan::Cap(_)) {
355            Some("a capped end has no corner to share the point with")
356        } else {
357            None
358        };
359        if let Some(reason) = unsupported {
360            return Err(KernelRefusal::unsupported(KernelStage::Sew, super::RAIL_COLLAPSE_WHAT, format!(
361                "{RAIL_COLLAPSE_UNSUPPORTED} edge {}'s rail shrinks to a point and {reason}",
362                edge.id
363            )));
364        }
365    }
366    let trim_rail = |collapsed: bool,
367                     row: &NurbsCurve,
368                     pcurve: &NurbsCurve,
369                     a: f64,
370                     b: f64|
371     -> Result<Option<(NurbsCurve, NurbsCurve)>, KernelRefusal> {
372        if collapsed {
373            return Ok(None);
374        }
375        Ok(Some((trim_row(row, a, b).map_err(describe_trim)?, trim_row(pcurve, a, b)?)))
376    };
377    let cr_rail = trim_rail(
378        cr_collapsed,
379        &rows.cr,
380        &rows.cr_pcurve,
381        start_end.cr_parameter(),
382        finish_end.cr_parameter(),
383    )?;
384    let cs_rail = trim_rail(
385        cs_collapsed,
386        &rows.cs,
387        &rows.cs_pcurve,
388        start_end.cs_parameter(),
389        finish_end.cs_parameter(),
390    )?;
391    // A collapsed rail's domain is its two stops, which are one point.
392    let cr_domain = match &cr_rail {
393        Some((cr, _)) => cr.domain().or_refuse(KernelStage::Refine, "domain")?,
394        None => [start_end.cr_parameter(), finish_end.cr_parameter()],
395    };
396    let cs_domain = match &cs_rail {
397        Some((cs, _)) => cs.domain().or_refuse(KernelStage::Refine, "domain")?,
398        None => [start_end.cs_parameter(), finish_end.cs_parameter()],
399    };
400    // A rail's end points, read off the trimmed rail or, collapsed, the row.
401    let cr_at = |parameter: f64| match &cr_rail {
402        Some((cr, _)) => cr.evaluate(parameter),
403        None => rows.cr.evaluate(parameter),
404    };
405    let cs_at = |parameter: f64| match &cs_rail {
406        Some((cs, _)) => cs.evaluate(parameter),
407        None => rows.cs.evaluate(parameter),
408    };
409    let old_start = edge.start_vertex_id;
410    let old_finish = edge.end_vertex_id;
411
412    // Classify each of the four support crossings.  A crossing that lands at
413    // the FAR endpoint of the boundary edge it meets (the end away from the
414    // corner) means a prior fillet already consumed that whole boundary: the
415    // new blend reuses the existing vertex there and the boundary edge is
416    // deleted (its role is taken over by the new transverse curve on the
417    // prior blend face).  An interior crossing is the pristine case — a fresh
418    // vertex with the boundary trimmed to it.
419    //
420    // Consumed means within `consumed_band` — the slack `check_support_extent`
421    // refuses past, so a rail between the two is not possible.
422    let classify =
423        |boundary_id: u64, boundary_param: f64, corner: u64| -> Result<RimResolution, KernelRefusal> {
424            let boundary = result
425                .edges
426                .iter()
427                .find(|candidate| candidate.id == boundary_id)
428                .ok_or(KernelRefusal::internal(KernelStage::Sew, "end_boundary_edge", "blend: end boundary edge missing during surgery"))?;
429            let span = (boundary.t1 - boundary.t0).abs().max(1e-12);
430            let far_vertex = if boundary.start_vertex_id == corner {
431                Some((boundary.end_vertex_id, boundary.t1, boundary.t0))
432            } else if boundary.end_vertex_id == corner {
433                Some((boundary.start_vertex_id, boundary.t0, boundary.t1))
434            } else {
435                None
436            };
437            if let Some((far_id, far_t, near_t)) = far_vertex {
438                // A consumed boundary is a COINCIDENCE — a prior fillet's rim
439                // vertex is exactly where this rail crosses — and the operands
440                // were healed before the march, so it holds to solver
441                // precision.  It is not "near the far end": a fillet whose
442                // radius is 1e-3 short of the face's width crosses 1e-3 from
443                // the far vertex and must keep that sliver, or the face is
444                // snapped a full 1e-3 out of true (the oversized-radius limit
445                // cases and issue 1177 measure exactly that).  Measured in
446                // model units, never in the boundary's parameter — a unit
447                // parameter on a 20-long edge would call 2e-3 a coincidence.
448                let far_point = result
449                    .vertices
450                    .iter()
451                    .find(|candidate| candidate.id == far_id)
452                    .map(|candidate| candidate.point)
453                    .ok_or(KernelRefusal::internal(KernelStage::Sew, "far_vertex", "blend: consumed boundary far vertex missing"))?;
454                let crossing = boundary.curve.evaluate_extended(boundary_param).or_refuse(KernelStage::Refine, "evaluate_extended")?;
455                let _ = span;
456                let past_far = crossing.sub(far_point).length();
457                if past_far <= band {
458                    // Sanity: the reused vertex must exist.
459                    if !result
460                        .vertices
461                        .iter()
462                        .any(|candidate| candidate.id == far_id)
463                    {
464                        return Err(KernelRefusal::internal(KernelStage::Sew, "far_vertex", "blend: consumed boundary far vertex missing"));
465                    }
466                    return Ok(RimResolution::Consumed(far_id));
467                }
468                // A crossing BEYOND the far vertex is a rail that runs off
469                // its face: the blend is wider than the face by more than the
470                // band a snap may close, and trimming the boundary there would
471                // extend it into air.  `check_support_extent` refuses this
472                // before the march on every face the exact tool understands;
473                // this is the refusal for the rest.  It is not the whole
474                // answer: a crossing the solve CLAMPS to the far vertex reads
475                // as consumed here, and `detect_row_coincidence` is what sees
476                // that row leave the face.  Measured on the census: it fires on
477                // a wall wider than its closed chain's arc (0.5 past) and on the
478                // r = 10.001 pinch past an earlier round (2e-3 past), both of
479                // which refused later in the surgery before.
480                if (boundary_param - far_t) * (far_t - near_t) > 0.0 {
481                    return Err(KernelRefusal::unsupported(KernelStage::Sew, "blend_wider_than_face", format!(
482                        "{BLEND_WIDER_THAN_FACE} its rail crosses edge {boundary_id} {past_far:.3e} \
483                         past that edge's far vertex, and a rail within {band:.3e} of it is the \
484                         most this surgery snaps onto the edge"
485                    )));
486                }
487            }
488            Ok(RimResolution::Fresh)
489        };
490    // A CORNER end has no boundary to classify: the ball's tangency vertex is
491    // the rim, and the boundary edge that used to reach the sharp corner is
492    // the neighbouring stripe's own blended edge, which that stripe replaces.
493    let resolve_side = |plan: &EndPlan,
494                        side_first: bool,
495                        corner: u64|
496     -> Result<RimResolution, KernelRefusal> {
497        if let Some(vertex) = plan.planned_rim(side_first) {
498            return Ok(RimResolution::Corner(vertex));
499        }
500        match plan {
501            EndPlan::Free(free) => classify(
502                if side_first {
503                    free.first_edge_id
504                } else {
505                    free.second_edge_id
506                },
507                if side_first {
508                    free.first_edge_parameter
509                } else {
510                    free.second_edge_parameter
511                },
512                corner,
513            ),
514            EndPlan::Corner(_) | EndPlan::Miter(_) | EndPlan::Cap(_) => {
515                Err(KernelRefusal::internal(KernelStage::Sew, "rim_vertices", "blend: planned end without rim vertices"))
516            }
517        }
518    };
519    let start_first = resolve_side(&start_end, true, old_start)?;
520    let start_second = resolve_side(&start_end, false, old_start)?;
521    let finish_first = resolve_side(&finish_end, true, old_finish)?;
522    let finish_second = resolve_side(&finish_end, false, old_finish)?;
523
524    // Manifold pairing: the blend's use of the first support row must oppose
525    // F1's use of the blended edge (see the blend-loop construction below).
526    let first_use_forward = first.coedge.forward;
527    let blend_cr_forward = !first_use_forward;
528
529    // Curve-level coincidence (OCCT PR #1449 lesson 3): when BOTH of a mate's
530    // crossings are endpoint-consumed AND the trimmed support row retraces the
531    // existing edge joining the two far vertices, that mate face is FULLY
532    // consumed — the surgery sews the blend face straight onto the existing
533    // edge and drops the zero-width face, instead of creating a coincident
534    // fresh support edge that would leave a sliver strip (the r = face-width
535    // class).  Each side is detected independently; any ambiguity keeps the
536    // pristine path (fail-safe).  `row_traversed_from_start`: the blend loop
537    // walks cr in station order iff `blend_cr_forward`, and cs in the
538    // OPPOSITE order (see the two loop branches below).
539    // A stripe with a CORNER end has no boundary edges at that end to be
540    // coincident with, so the detection only runs on stripes that are free at
541    // both ends.  (Fail-safe: not detecting a coincidence keeps the pristine
542    // path, which is what a corner stop wants anyway.)
543    let (sew_first, sew_second) = match (start_end.free(), finish_end.free(), &cr_rail, &cs_rail) {
544        (Some(start_free), Some(finish_free), Some((cr, _)), Some((cs, _))) => (
545            detect_row_coincidence(
546                solid,
547                first,
548                edge.id,
549                start_free.first_edge_id,
550                finish_free.first_edge_id,
551                start_first.is_consumed(),
552                start_first.existing().unwrap_or(0),
553                finish_first.is_consumed(),
554                finish_first.existing().unwrap_or(0),
555                cr,
556                blend_cr_forward,
557                band,
558            )?,
559            detect_row_coincidence(
560                solid,
561                second,
562                edge.id,
563                start_free.second_edge_id,
564                finish_free.second_edge_id,
565                start_second.is_consumed(),
566                start_second.existing().unwrap_or(0),
567                finish_second.is_consumed(),
568                finish_second.existing().unwrap_or(0),
569                cs,
570                !blend_cr_forward,
571                band,
572            )?,
573        ),
574        _ => (None, None),
575    };
576    // One face hosting both sides (the blended edge used twice by one face)
577    // cannot be dropped for one side while the other still splices into it —
578    // ambiguous, keep the pristine path for both.
579    let (sew_first, sew_second): (Option<RowSew>, Option<RowSew>) =
580        if first.face.id == second.face.id {
581            (None, None)
582        } else {
583            (sew_first, sew_second)
584        };
585
586    // Resolve the four rim vertices: reuse the existing vertex for a consumed
587    // side, else create a fresh vertex at the support-row endpoint.
588    let mut resolve = |rim: &RimResolution, point: Result<Vec3, String>| -> Result<u64, KernelRefusal> {
589        match rim.existing() {
590            Some(id) => Ok(id),
591            None => {
592                let id = take_id();
593                result.vertices.push(VertexRecord { id, point: point.or_refuse(KernelStage::Refine, "point")? });
594                Ok(id)
595            }
596        }
597    };
598    let w1a = resolve(&start_first, cr_at(cr_domain[0]))?;
599    let w1b = resolve(&finish_first, cr_at(cr_domain[1]))?;
600    let w2a = resolve(&start_second, cs_at(cs_domain[0]))?;
601    let w2b = resolve(&finish_second, cs_at(cs_domain[1]))?;
602    // How far the consumed rims and sewn rows moved a rail onto what already
603    // existed — what `check_snap_closure` measures the body against.
604    let mut snap = [&sew_first, &sew_second]
605        .into_iter()
606        .flatten()
607        .fold(0.0f64, |worst, sew| worst.max(sew.deviation));
608    for (rim, vertex, row_end) in [
609        (&start_first, w1a, cr_at(cr_domain[0]).or_refuse(KernelStage::Refine, "cr_at")?),
610        (&finish_first, w1b, cr_at(cr_domain[1]).or_refuse(KernelStage::Refine, "cr_at")?),
611        (&start_second, w2a, cs_at(cs_domain[0]).or_refuse(KernelStage::Refine, "cs_at")?),
612        (&finish_second, w2b, cs_at(cs_domain[1]).or_refuse(KernelStage::Refine, "cs_at")?),
613    ] {
614        if !rim.is_consumed() {
615            continue;
616        }
617        let point = result
618            .vertices
619            .iter()
620            .find(|candidate| candidate.id == vertex)
621            .ok_or(KernelRefusal::internal(KernelStage::Sew, "rim_vertex", "blend: consumed rim vertex missing"))?
622            .point;
623        snap = snap.max(row_end.sub(point).length());
624    }
625
626    // Support edges: a sewn side reuses the EXISTING coincident edge (no
627    // fresh edge — OCCT's `SetExistingEdge` move); a pristine side gets the
628    // fitted row as a fresh edge.
629    // A collapsed side builds no edge at all.
630    let cr_edge_id = match (&sew_first, &cr_rail) {
631        (Some(sew), _) => Some(sew.edge_id),
632        (None, None) => None,
633        (None, Some((cr, _))) => {
634            let id = take_id();
635            result.edges.push(EdgeRecord {
636                id,
637                curve: cr.clone(),
638                t0: cr_domain[0],
639                t1: cr_domain[1],
640                start_vertex_id: w1a,
641                end_vertex_id: w1b,
642                degenerate: false,
643                name: None,
644            });
645            Some(id)
646        }
647    };
648    let cs_edge_id = match (&sew_second, &cs_rail) {
649        (Some(sew), _) => Some(sew.edge_id),
650        (None, None) => None,
651        (None, Some((cs, _))) => {
652            let id = take_id();
653            result.edges.push(EdgeRecord {
654                id,
655                curve: cs.clone(),
656                t0: cs_domain[0],
657                t1: cs_domain[1],
658                start_vertex_id: w2a,
659                end_vertex_id: w2b,
660                degenerate: false,
661                name: None,
662            });
663            Some(id)
664        }
665    };
666    // The edge closing each end of the blend face: the §6.9 transverse curve
667    // on the end face for a free end, or — at a corner — the end
668    // cross-section arc the corner patch was already given, committed by the
669    // caller and merely referenced here.
670    let mut commit_end_edge = |plan: &EndPlan,
671                               first_rim: u64,
672                               second_rim: u64|
673     -> Result<Option<u64>, KernelRefusal> {
674        match plan {
675            EndPlan::Corner(_) | EndPlan::Miter(_) | EndPlan::Cap(_) => Ok(None),
676            EndPlan::Free(free) => {
677                let id = take_id();
678                let domain = free.transverse_curve.domain().or_refuse(KernelStage::Refine, "domain")?;
679                result.edges.push(EdgeRecord {
680                    id,
681                    curve: free.transverse_curve.clone(),
682                    t0: domain[0],
683                    t1: domain[1],
684                    start_vertex_id: first_rim,
685                    end_vertex_id: second_rim,
686                    degenerate: false,
687                    name: None,
688                });
689                Ok(Some(id))
690            }
691        }
692    };
693    let transverse_a_id = commit_end_edge(&start_end, w1a, w2a)?;
694    let transverse_b_id = commit_end_edge(&finish_end, w1b, w2b)?;
695
696    // Replace the blended edge in each PRISTINE mate's loop — the blend
697    // face's loop direction is forced by manifold pairing with F1's use of
698    // the blended edge (`first_use_forward`, captured above).  A sewn mate is
699    // dropped whole below; nothing to splice.  A COLLAPSED side has no rail to
700    // put there: the mate simply loses the blended edge, and its loop closes
701    // through the rim vertices the orchestrator identifies.
702    for (mate, rail, sewn) in [
703        (first, cr_edge_id.zip(cr_rail.as_ref()), sew_first.is_some()),
704        (second, cs_edge_id.zip(cs_rail.as_ref()), sew_second.is_some()),
705    ] {
706        if sewn {
707            continue;
708        }
709        let face = result
710            .shells
711            .iter_mut()
712            .flat_map(|shell| &mut shell.faces)
713            .find(|face| face.id == mate.face.id)
714            .ok_or(KernelRefusal::internal(KernelStage::Sew, "mate_face", "blend: mate face lost during surgery"))?;
715        let loop_record = &mut face.loops[mate.loop_index];
716        let position = loop_record
717            .coedges
718            .iter()
719            .position(|coedge| coedge.edge_id == edge.id)
720            .ok_or(KernelRefusal::internal(KernelStage::Sew, "edge_coedge", "blend: edge coedge lost during surgery"))?;
721        let Some((new_edge_id, (_, pcurve_forward))) = rail else {
722            loop_record.coedges.remove(position);
723            continue;
724        };
725        let old_forward = loop_record.coedges[position].forward;
726        loop_record.coedges[position] = CoedgeRecord {
727            id: loop_record.coedges[position].id,
728            edge_id: new_edge_id,
729            forward: old_forward,
730            pcurve: if old_forward {
731                pcurve_forward.clone()
732            } else {
733                pcurve_forward.reversed().or_refuse(KernelStage::Refine, "reversed")?
734            },
735        };
736    }
737
738    // A capped end's legs: on each mate, the leg runs from the rail's rim
739    // vertex to the sharp vertex, where the continuing edge still starts.
740    // It goes between the rail coedge and that continuing coedge, with the
741    // sense that walks rim -> vertex when the rail ends at the rim.
742    for (plan, at_start) in [(&start_end, true), (&finish_end, false)] {
743        let EndPlan::Cap(cap) = plan else {
744            continue;
745        };
746        for (mate, rail_edge_id, leg, rim) in [
747            (first, cr_edge_id, &cap.first_leg, if at_start { w1a } else { w1b }),
748            (second, cs_edge_id, &cap.second_leg, if at_start { w2a } else { w2b }),
749        ] {
750            // Refused above: a capped end never meets a collapsed rail.
751            let rail_edge_id = rail_edge_id.ok_or(KernelRefusal::internal(KernelStage::Sew, "end_rail", "blend: a capped end lost its rail"))?;
752            let face = result
753                .shells
754                .iter_mut()
755                .flat_map(|shell| &mut shell.faces)
756                .find(|face| face.id == mate.face.id)
757                .ok_or(KernelRefusal::internal(KernelStage::Sew, "mate_face", "blend: mate face lost during cap splice"))?;
758            let loop_record = &mut face.loops[mate.loop_index];
759            let count = loop_record.coedges.len();
760            let rail_at = loop_record
761                .coedges
762                .iter()
763                .position(|coedge| coedge.edge_id == rail_edge_id)
764                .ok_or(KernelRefusal::internal(KernelStage::Sew, "rail_coedge", "blend: rail coedge lost during cap splice"))?;
765            // Which side of the rail coedge is this end?  The rail coedge
766            // traverses rim-to-rim; the capped end is at its traversal END
767            // when walking forward means station order and this is the
768            // finish end, etc.  Decide by geometry: the neighbour whose
769            // traversal touches the sharp vertex.
770            let rail_forward = loop_record.coedges[rail_at].forward;
771            let end_is_traversal_end = if rail_forward { !at_start } else { at_start };
772            let (insert_at, forward, pcurve) = if end_is_traversal_end {
773                // rail ... rim -> [leg rim->vertex] -> continuing edge
774                (rail_at + 1, true, leg.1.clone())
775            } else {
776                // continuing edge -> [leg vertex->rim] -> rim ... rail
777                (rail_at, false, leg.1.reversed().or_refuse(KernelStage::Refine, "reversed")?)
778            };
779            let _ = (count, rim);
780            loop_record.coedges.insert(
781                insert_at,
782                CoedgeRecord {
783                    id: take_id(),
784                    edge_id: leg.0,
785                    forward,
786                    pcurve,
787                },
788            );
789        }
790    }
791
792    // Drop consumed boundary edges from the mate loop they share with the
793    // blended edge: the fresh support coedge already begins at the reused far
794    // vertex, so removing the fully-covered boundary keeps the loop closed.
795    // A SEWN mate's whole loop collapses (the face is dropped below), so its
796    // consumed boundaries are only recorded for deletion, never spliced.
797    let mut consumed_edges: Vec<u64> = Vec::new();
798    let start_free = start_end.free();
799    let finish_free = finish_end.free();
800    for (mate, sewn, sides) in [
801        (
802            first,
803            sew_first.is_some(),
804            [
805                (&start_first, start_free.map(|free| free.first_edge_id)),
806                (&finish_first, finish_free.map(|free| free.first_edge_id)),
807            ],
808        ),
809        (
810            second,
811            sew_second.is_some(),
812            [
813                (&start_second, start_free.map(|free| free.second_edge_id)),
814                (&finish_second, finish_free.map(|free| free.second_edge_id)),
815            ],
816        ),
817    ] {
818        for (cross, boundary_id) in sides {
819            if !cross.is_consumed() {
820                continue;
821            }
822            let Some(boundary_id) = boundary_id else {
823                continue;
824            };
825            if !sewn {
826                let face = result
827                    .shells
828                    .iter_mut()
829                    .flat_map(|shell| &mut shell.faces)
830                    .find(|face| face.id == mate.face.id)
831                    .ok_or(KernelRefusal::internal(KernelStage::Sew, "mate_face", "blend: mate face lost during surgery"))?;
832                face.loops[mate.loop_index]
833                    .coedges
834                    .retain(|coedge| coedge.edge_id != boundary_id);
835            }
836            if !consumed_edges.contains(&boundary_id) {
837                consumed_edges.push(boundary_id);
838            }
839        }
840    }
841    // Lesson 7: zero-span leftovers of a collapsing loop go with their face.
842    for sew in [&sew_first, &sew_second].into_iter().flatten() {
843        for id in &sew.collapsed_edges {
844            if !consumed_edges.contains(id) {
845                consumed_edges.push(*id);
846            }
847        }
848    }
849
850    // Locate the corner junction on each end face BEFORE trimming (the two
851    // boundary coedges that meet at the old corner vertex, stepping over the
852    // pole between them where the end face's apex IS that vertex) — edge ids
853    // alone are ambiguous on two-coedge cap loops, so the meeting must be at
854    // the corner.
855    // (end, transverse_id, corner, first_consumed, second_consumed) -> plan.
856    // Only FREE ends appear here: a corner stop rebuilds nothing on a third
857    // face, because the corner patch is what closes it.
858    let mut end_plans = Vec::with_capacity(2);
859    for (end, transverse_id, corner, first_consumed, second_consumed) in [
860        (
861            start_end.free(),
862            transverse_a_id,
863            old_start,
864            start_first.is_consumed(),
865            start_second.is_consumed(),
866        ),
867        (
868            finish_end.free(),
869            transverse_b_id,
870            old_finish,
871            finish_first.is_consumed(),
872            finish_second.is_consumed(),
873        ),
874    ] {
875        let (Some(end), Some(transverse_id)) = (end, transverse_id) else {
876            continue;
877        };
878        let face = result
879            .shells
880            .iter()
881            .flat_map(|shell| &shell.faces)
882            .find(|face| face.id == end.end_face_id)
883            .ok_or(KernelRefusal::internal(KernelStage::Sew, "end_face", "blend: end face lost during surgery"))?;
884        // Key on stable coedge ids (not indices) so processing one end can't
885        // invalidate another that shares this face.
886        let Some(located) = locate_end_corner(
887            &result,
888            face,
889            corner,
890            end.first_edge_id,
891            end.second_edge_id,
892        ) else {
893            return Err(KernelRefusal::internal(KernelStage::Sew, "end_face_corner", "blend: end-face corner (adjacent boundary coedges) not found"));
894        };
895        // The apex the transverse curve cuts off goes with the boundaries the
896        // blend consumes.
897        for pole in &located.pole_edge_ids {
898            if !consumed_edges.contains(pole) {
899                consumed_edges.push(*pole);
900            }
901        }
902        end_plans.push((
903            end.end_face_id,
904            transverse_id,
905            end.transverse_end_pcurve.clone(),
906            located,
907            first_consumed,
908            second_consumed,
909        ));
910    }
911
912    // Trim the boundary edges at their support crossings.  A consumed side is
913    // deleted instead of trimmed; a CORNER side touches no boundary at all.
914    for (rim_resolution, boundary, corner, rim) in [
915        (
916            &start_first,
917            start_free.map(|free| (free.first_edge_id, free.first_edge_parameter)),
918            old_start,
919            w1a,
920        ),
921        (
922            &start_second,
923            start_free.map(|free| (free.second_edge_id, free.second_edge_parameter)),
924            old_start,
925            w2a,
926        ),
927        (
928            &finish_first,
929            finish_free.map(|free| (free.first_edge_id, free.first_edge_parameter)),
930            old_finish,
931            w1b,
932        ),
933        (
934            &finish_second,
935            finish_free.map(|free| (free.second_edge_id, free.second_edge_parameter)),
936            old_finish,
937            w2b,
938        ),
939    ] {
940        if !rim_resolution.trims_boundary() {
941            continue;
942        }
943        let Some((boundary_id, boundary_param)) = boundary else {
944            continue;
945        };
946        trim_edge_at(result, boundary_id, boundary_param, corner, rim)?;
947    }
948
949    // Rebuild each end face's corner: drop the consumed boundary coedge(s) and
950    // splice in the transverse coedge between the (trimmed) boundaries.
951    for (end_face, transverse_id, transverse_end_pcurve, located, first_consumed, second_consumed) in
952        end_plans
953    {
954        let forward = located.x_is_first;
955        let pcurve = if forward {
956            transverse_end_pcurve
957        } else {
958            transverse_end_pcurve.reversed().or_refuse(KernelStage::Refine, "reversed")?
959        };
960        let (x_consumed, y_consumed) = if located.x_is_first {
961            (first_consumed, second_consumed)
962        } else {
963            (second_consumed, first_consumed)
964        };
965        let transverse_coedge = CoedgeRecord {
966            id: take_id(),
967            edge_id: transverse_id,
968            forward,
969            pcurve,
970        };
971        splice_end_corner(
972            result,
973            end_face,
974            &located,
975            transverse_coedge,
976            x_consumed,
977            y_consumed,
978        )?;
979    }
980
981    // Blend face: cr fwd -> TB -> cs rev -> TA rev in (t, z) space.  Both rows
982    // share one parameterisation, so a collapsed cr reads its sense across the
983    // strip the cs rail still spans (a stripe with BOTH rails collapsed has no
984    // area; the orchestrator drops it with the edges that bound it).
985    let (mid_u, station_uv) = match (&cr_rail, &cs_rail) {
986        (Some((_, cr_pcurve)), _) => {
987            let mid_u = (cr_domain[0] + cr_domain[1]) * 0.5;
988            (mid_u, cr_pcurve.evaluate(mid_u).or_refuse(KernelStage::Refine, "evaluate")?)
989        }
990        (None, Some(_)) => {
991            let mid_u = (cs_domain[0] + cs_domain[1]) * 0.5;
992            (mid_u, rows.cr_pcurve.evaluate(mid_u).or_refuse(KernelStage::Refine, "evaluate")?)
993        }
994        (None, None) => (cr_domain[0], rows.cr_pcurve.evaluate(cr_domain[0]).or_refuse(KernelStage::Refine, "evaluate")?),
995    };
996    let blend_normal = raw_normal(&rows.surface, mid_u, 0.0)?;
997    let n1 = raw_normal(&first.face.surface, station_uv.x, station_uv.y)?;
998    let out1 = if first.face.same_sense {
999        n1
1000    } else {
1001        n1.scale(-1.0)
1002    };
1003    let same_sense = blend_normal.dot(out1) >= 0.0;
1004    let loop_id = take_id();
1005    // Traversal senses of the blend's support coedges.  A fresh support edge
1006    // is parameterized in station order, so the sense is the loop's walking
1007    // direction (`blend_cr_forward` for cr, its opposite for cs); a SEWN
1008    // side's sense comes from the detection (the existing edge's own
1009    // parameter direction relative to that same walk — equal to the dropped
1010    // face's old sense, preserving manifold pairing with the survivor).  The
1011    // pcurves always follow the LOOP's walking direction and are unchanged.
1012    let cr_forward = sew_first
1013        .as_ref()
1014        .map_or(blend_cr_forward, |sew| sew.forward);
1015    let cs_forward = sew_second
1016        .as_ref()
1017        .map_or(!blend_cr_forward, |sew| sew.forward);
1018    // The two end slots of the blend loop.  `natural_forward` is the sense the
1019    // loop walks a transverse edge stored first-rim -> second-rim; a CORNER
1020    // arc was already committed in the walk direction (so the corner patch can
1021    // take it the other way round), so it is always used forward.
1022    // A slot is one coedge for a free end (the transverse curve) or a corner
1023    // end (the section arc), and one or two for a miter end (the seam, then
1024    // the sibling's section when the seam left the sibling first).  A corner
1025    // arc was committed in the walk direction; a miter edge records its own
1026    // sense along the walk.
1027    let mut end_slot = |plan: &EndPlan,
1028                        free_edge_id: Option<u64>,
1029                        natural_forward: bool|
1030     -> Result<Vec<CoedgeRecord>, KernelRefusal> {
1031        match plan {
1032            EndPlan::Corner(end) => Ok(vec![CoedgeRecord {
1033                id: take_id(),
1034                edge_id: end.arc_edge_id,
1035                forward: true,
1036                pcurve: end.arc_blend_pcurve.clone(),
1037            }]),
1038            EndPlan::Cap(end) => Ok(vec![CoedgeRecord {
1039                id: take_id(),
1040                edge_id: end.arc_edge_id,
1041                forward: true,
1042                pcurve: end.arc_blend_pcurve.clone(),
1043            }]),
1044            EndPlan::Miter(end) => end
1045                .edges
1046                .iter()
1047                .map(|(edge_id, forward, pcurve)| {
1048                    Ok(CoedgeRecord {
1049                        id: take_id(),
1050                        edge_id: *edge_id,
1051                        forward: *forward,
1052                        pcurve: pcurve.clone(),
1053                    })
1054                })
1055                .collect(),
1056            EndPlan::Free(free) => {
1057                let edge_id =
1058                    free_edge_id.ok_or(KernelRefusal::internal(KernelStage::Sew, "transverse_edge", "blend: free end without its transverse edge"))?;
1059                Ok(vec![if natural_forward {
1060                    CoedgeRecord {
1061                        id: take_id(),
1062                        edge_id,
1063                        forward: true,
1064                        pcurve: free.transverse_blend_pcurve.clone(),
1065                    }
1066                } else {
1067                    CoedgeRecord {
1068                        id: take_id(),
1069                        edge_id,
1070                        forward: false,
1071                        pcurve: free.transverse_blend_pcurve.reversed().or_refuse(KernelStage::Refine, "reversed")?,
1072                    }
1073                }])
1074            }
1075        }
1076    };
1077    let start_slot = end_slot(&start_end, transverse_a_id, !blend_cr_forward)?;
1078    let finish_slot = end_slot(&finish_end, transverse_b_id, blend_cr_forward)?;
1079    // A collapsed rail has no coedge: the end slots on either side of it meet
1080    // at its one rim point.
1081    let coedges = if blend_cr_forward {
1082        let mut coedges = Vec::new();
1083        if let Some(cr_edge_id) = cr_edge_id {
1084            coedges.push(CoedgeRecord {
1085                id: take_id(),
1086                edge_id: cr_edge_id,
1087                forward: cr_forward,
1088                pcurve: crate::sweep_topology::parameter_line(
1089                    cr_domain[0],
1090                    0.0,
1091                    cr_domain[1],
1092                    0.0,
1093                ).or_refuse(KernelStage::Refine, "parameter_line")?,
1094            });
1095        }
1096        coedges.extend(finish_slot);
1097        if let Some(cs_edge_id) = cs_edge_id {
1098            coedges.push(CoedgeRecord {
1099                id: take_id(),
1100                edge_id: cs_edge_id,
1101                forward: cs_forward,
1102                pcurve: cs_blend_pcurve_reversed(&cs_domain)?,
1103            });
1104        }
1105        coedges.extend(start_slot);
1106        coedges
1107    } else {
1108        let mut coedges = Vec::new();
1109        if let Some(cr_edge_id) = cr_edge_id {
1110            coedges.push(CoedgeRecord {
1111                id: take_id(),
1112                edge_id: cr_edge_id,
1113                forward: cr_forward,
1114                pcurve: crate::sweep_topology::parameter_line(
1115                    cr_domain[1],
1116                    0.0,
1117                    cr_domain[0],
1118                    0.0,
1119                ).or_refuse(KernelStage::Refine, "parameter_line")?,
1120            });
1121        }
1122        coedges.extend(start_slot);
1123        if let Some(cs_edge_id) = cs_edge_id {
1124            coedges.push(CoedgeRecord {
1125                id: take_id(),
1126                edge_id: cs_edge_id,
1127                forward: cs_forward,
1128                pcurve: crate::sweep_topology::parameter_line(
1129                    cs_domain[0],
1130                    1.0,
1131                    cs_domain[1],
1132                    1.0,
1133                ).or_refuse(KernelStage::Refine, "parameter_line")?,
1134            });
1135        }
1136        coedges.extend(finish_slot);
1137        coedges
1138    };
1139    let blend_face_id = take_id();
1140    let mut blend_face = FaceRecord {
1141        id: blend_face_id,
1142        surface: rows.surface,
1143        same_sense,
1144        loops: vec![LoopRecord {
1145            id: loop_id,
1146            coedges,
1147        }],
1148        name: name.map(|value| value.to_string()),
1149    };
1150    if rows.exact_extrusion {
1151        transpose_face(&mut blend_face)?;
1152    }
1153    let shell_index = result
1154        .shells
1155        .iter()
1156        .position(|shell| shell.faces.iter().any(|face| face.id == first.face.id))
1157        .ok_or(KernelRefusal::internal(KernelStage::Sew, "mate_shell", "blend: mate shell lost during surgery"))?;
1158    // Drop the fully-consumed mate faces (lesson 3): their loops collapsed
1159    // between the sewn edge and the blend; the consumed boundaries and
1160    // lesson-7 leftovers are deleted below, the sewn edge lives on shared by
1161    // the blend face and the surviving neighbour.
1162    for (mate, sew) in [(first, &sew_first), (second, &sew_second)] {
1163        if sew.is_some() {
1164            for shell in &mut result.shells {
1165                shell.faces.retain(|face| face.id != mate.face.id);
1166            }
1167        }
1168    }
1169    result.shells[shell_index].faces.push(blend_face);
1170
1171    result.edges.retain(|candidate| candidate.id != edge.id);
1172    // Delete the boundary edges a prior fillet's corner had left behind that
1173    // this blend fully consumed.
1174    result
1175        .edges
1176        .retain(|candidate| !consumed_edges.contains(&candidate.id));
1177    // Each collapsed rail's two rims are one vertex.
1178    let mut identified = Vec::new();
1179    for (collapsed, from, to) in [(cr_collapsed, w1a, w1b), (cs_collapsed, w2a, w2b)] {
1180        if collapsed && from != to {
1181            identified.push((from, to));
1182        }
1183    }
1184    Ok(SewnStripe {
1185        cr_edge_id,
1186        cs_edge_id,
1187        blend_face_id,
1188        consumed: [sew_first.is_some(), sew_second.is_some()],
1189        snap,
1190        identified,
1191    })
1192}
1193
1194/// Swap the two parameters of a face: the surface's control net, every
1195/// coedge pcurve, and the sense (S_u × S_v changes sign).  The face's
1196/// geometry is untouched; only its chart is.  Used to hand an exact
1197/// cylinder patch built with its section along v to the analytic recogniser,
1198/// which wants the circle along u.
1199pub(in crate::blend) fn transpose_face(face: &mut FaceRecord) -> Result<(), KernelRefusal> {
1200    let surface = &face.surface;
1201    let rows_u = surface.control_points.len();
1202    let rows_v = surface.control_points.first().map(|row| row.len()).unwrap_or(0);
1203    let mut transposed = vec![Vec::with_capacity(rows_u); rows_v];
1204    for row in &surface.control_points {
1205        for (j, point) in row.iter().enumerate() {
1206            transposed[j].push(*point);
1207        }
1208    }
1209    face.surface = NurbsSurface::new(
1210        surface.degree_v,
1211        surface.degree_u,
1212        surface.knots_v.clone(),
1213        surface.knots_u.clone(),
1214        transposed,
1215    ).or_refuse(KernelStage::Refine, "new")?;
1216    for loop_record in &mut face.loops {
1217        for coedge in &mut loop_record.coedges {
1218            for control in &mut coedge.pcurve.control_points {
1219                std::mem::swap(&mut control.x, &mut control.y);
1220            }
1221        }
1222    }
1223    face.same_sense = !face.same_sense;
1224    Ok(())
1225}
1226
1227/// Drop every vertex no longer referenced by an edge — the sharp corners the
1228/// blends replaced, and anything freed by a consumed boundary.  Run ONCE after
1229/// a whole group of stripes is in: a corner vertex is still referenced by the
1230/// selected edges that have not been sewn yet.
1231pub(in crate::blend) fn prune_orphan_vertices(result: &mut BrepSolid) {
1232    let used: rustc_hash::FxHashSet<u64> = result
1233        .edges
1234        .iter()
1235        .flat_map(|candidate| [candidate.start_vertex_id, candidate.end_vertex_id])
1236        .collect();
1237    result
1238        .vertices
1239        .retain(|candidate| used.contains(&candidate.id));
1240}
1241
1242fn cs_blend_pcurve_reversed(cs_domain: &[f64; 2]) -> Result<NurbsCurve, KernelRefusal> {
1243    crate::sweep_topology::parameter_line(cs_domain[1], 1.0, cs_domain[0], 1.0).or_refuse(KernelStage::Refine, "parameter_line")
1244}
1245