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