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