Skip to main content

brep_kernel/blending/blend/edge/
open.rs

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