Skip to main content

ogeom_fillet/
bridge.rs

1//! Bridging a gap: a blend surface between edges of two faces, and a blend
2//! curve between the ends of two edges.
3//!
4//! Both are Hermite constructions across the gap. Each side contributes its
5//! position, a crossing tangent pointing off its face (or along its curve)
6//! for G1, and the second derivative that matches its curvature for G2; the
7//! polynomial across is the Bézier of the least degree holding all of them.
8//!
9//! The surface runs along the two edges in `u` and across the gap in `v`,
10//! with `v = 0` on the first edge and `v = 1` on the second. Its rows next
11//! to each edge are that edge's own B-spline control polygon, made
12//! compatible with the other's (the same degree and knots), so the edges
13//! lie on the surface exactly and are shared as they are, gaining a trim on
14//! the blend. The crossing derivatives along each edge are B-splines on the
15//! same knots, interpolated at the Greville abscissae. On a plane every
16//! interpolated tangent lies in the plane, so the join is tangent (and for
17//! G2 curvature continuous) exactly; on a curved face the join is measured
18//! along the edge and the knots refined until it is within budget.
19
20use ogeom_algo::{
21    Built, History, attach_pcurve, edge_vertices, make_edge_between, make_face_on, make_wire,
22};
23use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
24use ogeom_geom::{
25    BSpline2d, BSplineCurve, BSplineSurface, Continuity, Curve, Curve2d as _, Curve3d as _, Line2d,
26    PlanarCurve, Surface as _, SurfaceGeometry,
27};
28use ogeom_math::{
29    Axis2, ControlGrid, Direction, Direction2, KnotVector, Point, Point2, Vector, Vector2, Weighted,
30};
31use ogeom_topo::{
32    EdgeRepr, Location, Model, NodeData, Orientation, Shape, ShapeType, explore_unique,
33};
34
35/// One end of an edge, in the direction the edge is traversed: the start of
36/// a reversed edge is the end of its curve's range.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum End {
39    /// Where the edge begins.
40    Start,
41    /// Where the edge finishes.
42    End,
43}
44
45/// The largest angle, in radians, between the blend's normal and a face's
46/// at a sampled station on their shared edge, for a G1 or G2 join.
47const TANGENCY_BUDGET: f64 = 1e-5;
48
49/// The largest difference between the blend's and a face's normal
50/// curvatures square to the edge at a station, for a G2 join, as a fraction
51/// of the inverse of the gap's width there.
52const CURVATURE_BUDGET: f64 = 1e-4;
53
54/// How many times the knots along the edges are halved before a join that
55/// stays outside its budget is refused.
56const REFINEMENTS: usize = 8;
57
58/// Stations measured in each knot span along the edges.
59const STATIONS_PER_SPAN: usize = 4;
60
61/// Bridge the gap between two edges with a blend face.
62///
63/// `first` and `second` are each an edge and the face it bounds. The blend
64/// is a B-spline face whose boundary is the two edges, shared as they are,
65/// and two new side edges joining their ends, paired so the sides do not
66/// cross. It meets each face with the continuity asked of that side:
67///
68/// - [`Continuity::C0`]: along the edge only;
69/// - [`Continuity::G1`]: tangent to the face, its normal within `1e-5`
70///   radians of the face's at every sampled station;
71/// - [`Continuity::G2`]: tangent as above, and bending alike square to the
72///   edge, the two normal curvatures there apart by at most `1e-4` divided
73///   by the gap's width.
74///
75/// The crossing tangent's length on each side is the gap's width, the
76/// distance between the two edges at the same `u`, so a blend across a
77/// wider stretch of gap swings out further. Joins to planar faces are exact; on a
78/// curved face the knots along the edges are refined until the measured join
79/// is within budget.
80///
81/// A placed edge or face (a prism's far end edges are its profile's edges
82/// under the prism's translation) is read where it stands: the face is
83/// rebuilt with its placements baked in, and the blend is bounded by the
84/// edge's twin on it, a new edge on the same geometry that sews to the
85/// given one.
86///
87/// Each edge gains a trim on the blend's surface. History: each edge
88/// generates the blend face and the two side edges, and its baked twin
89/// where it was placed.
90///
91/// # Errors
92///
93/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction), by name,
94/// where:
95///
96/// - a continuity is [`Continuity::C1`], [`Continuity::C2`] or
97///   [`Continuity::CInfinity`]: a blend's joins are geometric, to G2 at most;
98/// - a shape is not an edge or a face, an edge is not on the face given with
99///   it, or both are the same edge;
100/// - an edge is degenerate, closed, a seam of its face, or has no trim on its
101///   face's surface;
102/// - an edge's curve has no exact B-spline form on its own parameter (a
103///   circle or an ellipse, whose B-spline form runs on another), so it
104///   cannot be shared with the blend exactly;
105/// - the face's side of an edge cannot be told from its boundary, or the face
106///   has no normal at a point of the edge;
107/// - the edges meet at an end, where the blend would have no side;
108/// - on a curved face, the join stays outside its budget after refinement.
109pub fn make_blend_surface(
110    model: &mut Model,
111    first: (&Shape, &Shape),
112    second: (&Shape, &Shape),
113    continuity: (Continuity, Continuity),
114    tol: Tolerances,
115) -> OgeomResult<Built> {
116    let orders = (order_of(continuity.0)?, order_of(continuity.1)?);
117    if first.0.is_same(second.0) {
118        ogeom_bail!(
119            Construction,
120            "a blend bridges two different edges; both are the same one"
121        );
122    }
123    let (near_edge, near_face) = unplaced(model, first.0, first.1, tol)?;
124    let (far_edge, far_face) = unplaced(model, second.0, second.1, tol)?;
125    let mut near = read_side(model, &near_edge, &near_face, orders.0, tol)?;
126    let mut far = read_side(model, &far_edge, &far_face, orders.1, tol)?;
127
128    // The second edge runs whichever way pairs its ends with the first's
129    // without the sides crossing.
130    let (a0, a1) = (near.spline.start(tol)?, near.spline.end(tol)?);
131    let (b0, b1) = (far.spline.start(tol)?, far.spline.end(tol)?);
132    far.reversed = a0.distance(b1) + a1.distance(b0) < a0.distance(b0) + a1.distance(b1);
133    if far.reversed {
134        let (knots, control) =
135            ogeom_math::bspline::reverse(far.spline.knots(), far.spline.control_points());
136        far.spline = BSplineCurve::rational(knots, control)?;
137    }
138    let (b0, b1) = (far.spline.start(tol)?, far.spline.end(tol)?);
139    if a0.distance(b0).min(a1.distance(b1)) <= tol.confusion() {
140        ogeom_bail!(
141            Construction,
142            "the edges meet at an end; a blend between them would have no side there"
143        );
144    }
145
146    compatible(&mut near.spline, &mut far.spline, tol)?;
147    let mut rounds = 0;
148    let patch = loop {
149        let patch = hermite(&near, &far, tol)?;
150        let near_worst = measure(&patch, &near, &far, 0.0, tol)?;
151        let far_worst = measure(&patch, &near, &far, 1.0, tol)?;
152        let angle = near_worst.0.max(far_worst.0);
153        let bend = near_worst.1.max(far_worst.1);
154        if angle <= TANGENCY_BUDGET && bend <= CURVATURE_BUDGET {
155            break patch;
156        }
157        if rounds == REFINEMENTS {
158            ogeom_bail!(
159                Construction,
160                "the blend meets its faces {angle} radians off tangent and {bend} off their \
161                 curvature (as a fraction of the gap's inverse width) after refining its knots \
162                 {REFINEMENTS} times, outside the budget of {TANGENCY_BUDGET} and \
163                 {CURVATURE_BUDGET}"
164            );
165        }
166        refine(&mut near.spline, &mut far.spline, tol)?;
167        rounds += 1;
168    };
169
170    let sides_of = [
171        Curve::BSpline(patch.iso_u_curve(0.0, tol)?),
172        Curve::BSpline(patch.iso_u_curve(1.0, tol)?),
173    ];
174    let surface_id = model
175        .geometry_mut()
176        .add_surface(SurfaceGeometry::BSpline(patch));
177    let ends_of = |side: &Side| {
178        if side.reversed {
179            (side.vertices.1.clone(), side.vertices.0.clone())
180        } else {
181            side.vertices.clone()
182        }
183    };
184    let (near_ends, far_ends) = (ends_of(&near), ends_of(&far));
185    let [at_start, at_end] = sides_of;
186    let side_start = side_edge(
187        model,
188        at_start,
189        0.0,
190        (&near_ends.0, &far_ends.0),
191        surface_id,
192        tol,
193    )?;
194    let side_end = side_edge(
195        model,
196        at_end,
197        1.0,
198        (&near_ends.1, &far_ends.1),
199        surface_id,
200        tol,
201    )?;
202    for (side, v) in [(&near, 0.0), (&far, 1.0)] {
203        let (s0, s1) = if side.reversed {
204            (1.0, 0.0)
205        } else {
206            (0.0, 1.0)
207        };
208        let knots = KnotVector::new(
209            vec![side.range.0, side.range.0, side.range.1, side.range.1],
210            1,
211        )?;
212        let trim = BSpline2d::new(knots, vec![Point2::new(s0, v), Point2::new(s1, v)], tol)?;
213        attach_pcurve(
214            model,
215            &side.edge,
216            trim.into(),
217            surface_id,
218            Location::identity(),
219            side.range,
220        )?;
221    }
222
223    let ring = [
224        near.edge.clone(),
225        side_end.clone(),
226        if far.reversed {
227            far.edge.clone()
228        } else {
229            far.edge.reversed()
230        },
231        side_start.reversed(),
232    ];
233    let wire = make_wire(model, &ring, tol)?.shape;
234    let face = make_face_on(model, surface_id, &[wire], tol)?.shape;
235
236    let mut history = History::new();
237    for (edge, twin) in [(first.0, &near_edge), (second.0, &far_edge)] {
238        history.generate(edge, face.clone());
239        history.generate(edge, side_start.clone());
240        history.generate(edge, side_end.clone());
241        if !twin.is_same(edge) {
242            history.generate(edge, twin.clone());
243        }
244    }
245    Ok(Built::new(face, history))
246}
247
248/// An edge and the face it bounds, read where they stand: themselves where
249/// nothing about the face is placed, otherwise the face rebuilt with its
250/// placements baked in and the edge's twin on it.
251fn unplaced(
252    model: &mut Model,
253    edge: &Shape,
254    face: &Shape,
255    tol: Tolerances,
256) -> OgeomResult<(Shape, Shape)> {
257    if model.kind_of(face)? != ShapeType::Face {
258        return Ok((edge.clone(), face.clone()));
259    }
260    let baked = ogeom_algo::baked_where_placed(model, face, tol)?;
261    if baked.history.is_empty() {
262        return Ok((edge.clone(), face.clone()));
263    }
264    let Some(twin) = baked.history.modified(edge).first().cloned() else {
265        ogeom_bail!(Construction, "the edge is not on the face given with it");
266    };
267    let twin = if edge.orientation() == Orientation::Reversed {
268        twin.reversed()
269    } else {
270        twin
271    };
272    Ok((twin, baked.shape))
273}
274
275/// Bridge the gap between the ends of two edges with a blend edge.
276///
277/// The blend is a Bézier curve from the end of `a` to the end of `b`,
278/// joining their vertices. On each side it continues the edge with the
279/// continuity asked: [`Continuity::C0`] meets it, [`Continuity::G1`] also
280/// leaves along its tangent, [`Continuity::G2`] also with its curvature
281/// vector. The tangent's length on each side is the distance between the
282/// two ends.
283///
284/// History: each edge generates the blend edge.
285///
286/// # Errors
287///
288/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction), by name,
289/// where a continuity is [`Continuity::C1`], [`Continuity::C2`] or
290/// [`Continuity::CInfinity`]; a shape is not an edge, is placed or
291/// degenerate, or has no curve; an edge stands still at the end asked, so
292/// it has no tangent there; or the two ends are one point.
293pub fn make_blend_curve(
294    model: &mut Model,
295    a: (&Shape, End),
296    b: (&Shape, End),
297    continuity: (Continuity, Continuity),
298    tol: Tolerances,
299) -> OgeomResult<Built> {
300    let orders = (order_of(continuity.0)?, order_of(continuity.1)?);
301    let (from, near) = joint(model, a.0, a.1, orders.0, tol)?;
302    let (to, far) = joint(model, b.0, b.1, orders.1, tol)?;
303    let width = near.point.distance(far.point);
304    if width <= tol.confusion() || from.is_same(&to) {
305        ogeom_bail!(
306            Construction,
307            "the two ends are one point; there is no gap to bridge"
308        );
309    }
310    let control = near.column(&far, width);
311    let degree = control.len() - 1;
312    let mut knots = vec![0.0; degree + 1];
313    knots.extend(std::iter::repeat_n(1.0, degree + 1));
314    let curve = BSplineCurve::new(KnotVector::new(knots, degree)?, control, tol)?;
315    let edge = make_edge_between(model, Curve::BSpline(curve), (0.0, 1.0), &from, &to, tol)?.shape;
316    let mut history = History::new();
317    history.generate(a.0, edge.clone());
318    history.generate(b.0, edge.clone());
319    Ok(Built::new(edge, history))
320}
321
322/// How many derivatives across a join `continuity` asks to match.
323fn order_of(continuity: Continuity) -> OgeomResult<usize> {
324    match continuity {
325        Continuity::C0 => Ok(0),
326        Continuity::G1 => Ok(1),
327        Continuity::G2 => Ok(2),
328        Continuity::C1 | Continuity::C2 => ogeom_bail!(
329            Construction,
330            "a blend's join is geometric: parametric continuity would tie it to the two \
331             sides' parameterizations; ask G1 or G2"
332        ),
333        Continuity::CInfinity => ogeom_bail!(
334            Construction,
335            "a blend is built to G2 at most; a join smooth to every order is not offered"
336        ),
337    }
338}
339
340/// One side of the blend: an edge, its face, and the edge's curve as a
341/// B-spline on `[0, 1]` that runs on the edge's own parameter, scaled.
342struct Side {
343    /// The edge, forward.
344    edge: Shape,
345    /// Its vertices at the start and the end of its curve's range.
346    vertices: (Shape, Shape),
347    /// The face's surface.
348    surface: SurfaceGeometry,
349    /// The edge's trim on it, with the trim's range.
350    trim: (PlanarCurve, (f64, f64)),
351    /// The edge's curve and range.
352    curve: Curve,
353    range: (f64, f64),
354    /// The curve as a B-spline on `[0, 1]`, reversed where `reversed`.
355    spline: BSplineCurve,
356    /// Whether the blend's `u` runs against the edge.
357    reversed: bool,
358    /// The sign that turns the face's normal crossed with the edge's
359    /// tangent into the direction off the face.
360    off: f64,
361    /// How many derivatives across the join are matched.
362    order: usize,
363}
364
365/// Where the blend's crossing tangent starts on one side, at one station.
366struct Station {
367    /// The face's unit normal.
368    normal: Vector,
369    /// The unit direction square to the edge, in the face's tangent plane,
370    /// pointing off the face.
371    off: Vector,
372    /// The face's normal curvature along `off` times its normal: the second
373    /// derivative a unit-speed crossing curve has across the edge.
374    bend: Vector,
375    /// The edge's tangent.
376    along: Vector,
377}
378
379/// The side `edge` of `face` gives the blend, checked.
380fn read_side(
381    model: &Model,
382    edge: &Shape,
383    face: &Shape,
384    order: usize,
385    tol: Tolerances,
386) -> OgeomResult<Side> {
387    if model.kind_of(edge)? != ShapeType::Edge {
388        ogeom_bail!(
389            Construction,
390            "a blend surface bridges edges; got a {:?}",
391            model.kind_of(edge)?
392        );
393    }
394    if model.kind_of(face)? != ShapeType::Face {
395        ogeom_bail!(
396            Construction,
397            "each edge is given with the face it bounds; got a {:?}",
398            model.kind_of(face)?
399        );
400    }
401    let Some(NodeData::Face(face_data)) = model.node(face).map(|n| n.data()) else {
402        ogeom_bail!(Dangling, "face is not in this model");
403    };
404    let surface_id = face_data.surface;
405    if !explore_unique(model, face, ShapeType::Edge)?
406        .iter()
407        .any(|e| e.is_same(edge))
408    {
409        ogeom_bail!(Construction, "the edge is not on the face given with it");
410    }
411    let Some(surface) = model.geometry().surface(surface_id).cloned() else {
412        ogeom_bail!(Dangling, "face refers to a surface not in this model");
413    };
414    let Some(NodeData::Edge(data)) = model.node(edge).map(|n| n.data()) else {
415        ogeom_bail!(Dangling, "edge is not in this model");
416    };
417    if data.degenerate {
418        ogeom_bail!(
419            Construction,
420            "a degenerate edge has no extent to blend from"
421        );
422    }
423    let Some(EdgeRepr::Curve3d {
424        curve,
425        location,
426        range,
427    }) = data.curve3d()
428    else {
429        ogeom_bail!(Construction, "the edge has no curve in space");
430    };
431    if !location.is_identity() {
432        ogeom_bail!(
433            Construction,
434            "a placed edge is not blended; bake its placement into its geometry first"
435        );
436    }
437    let range = *range;
438    let Some(curve) = model.geometry().curve(*curve).cloned() else {
439        ogeom_bail!(Dangling, "curve is not in this model");
440    };
441    let trim = match data.pcurve_for(surface_id, edge.location()) {
442        Some(EdgeRepr::PCurve { curve, range, .. }) => {
443            let Some(pcurve) = model.geometry().pcurve(*curve).cloned() else {
444                ogeom_bail!(Dangling, "pcurve is not in this model");
445            };
446            (pcurve, *range)
447        }
448        Some(EdgeRepr::Seam { .. }) => ogeom_bail!(
449            Construction,
450            "the edge is a seam of its face; there is no one side to blend from"
451        ),
452        _ => ogeom_bail!(
453            Construction,
454            "the edge has no trim on its face's surface, so the face's side of it is unknown"
455        ),
456    };
457    let edge = edge.oriented(Orientation::Forward);
458    let Some((start, end)) = edge_vertices(model, &edge)? else {
459        ogeom_bail!(Construction, "the edge has no vertices");
460    };
461    if start.is_same(&end)
462        || curve
463            .point_at(range.0, tol)?
464            .distance(curve.point_at(range.1, tol)?)
465            <= tol.confusion()
466    {
467        ogeom_bail!(
468            Construction,
469            "the edge is closed; a blend's sides run from the ends of its edges"
470        );
471    }
472
473    let spline = match curve.to_bspline_over(range, tol) {
474        Ok(spline) if !spline.is_periodic() => spline,
475        _ => ogeom_bail!(
476            Construction,
477            "the edge's curve has no exact B-spline form to share with the blend"
478        ),
479    };
480    for k in 0..=8 {
481        let f = f64::from(k) / 8.0;
482        let on_edge = curve.point_at((range.1 - range.0).mul_add(f, range.0), tol)?;
483        if spline.point_at(f, tol)?.distance(on_edge) > tol.confusion() {
484            ogeom_bail!(
485                Construction,
486                "the edge's curve runs on a parameter its B-spline form does not share (as a \
487                 circle's or an ellipse's does), so the blend cannot share the edge exactly"
488            );
489        }
490    }
491
492    let mut side = Side {
493        edge,
494        vertices: (start, end),
495        surface,
496        trim,
497        curve,
498        range,
499        spline,
500        reversed: false,
501        off: 1.0,
502        order,
503    };
504    side.off = off_sign(model, face, &side, tol)?;
505    Ok(side)
506}
507
508/// Which way the face's normal crossed with the edge's tangent points
509/// relative to the face: `1` where it points off it, `-1` where onto it.
510/// Read at the edge's middle by stepping either way across its trim.
511fn off_sign(model: &Model, face: &Shape, side: &Side, tol: Tolerances) -> OgeomResult<f64> {
512    let rings = ogeom_mesh::face_boundary(model, face, ogeom_mesh::Deflection::default(), tol)?;
513    let (mut lo, mut hi) = (
514        Point2::new(f64::INFINITY, f64::INFINITY),
515        Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY),
516    );
517    for p in rings.iter().flatten() {
518        lo = Point2::new(lo.x.min(p.x), lo.y.min(p.y));
519        hi = Point2::new(hi.x.max(p.x), hi.y.max(p.y));
520    }
521    let size = lo.distance(hi);
522    let (pcurve, prange) = &side.trim;
523    let p = f64::midpoint(prange.0, prange.1);
524    let uv = pcurve.point_at(p, tol)?;
525    let heading = pcurve.d1_at(p, tol)?;
526    let across = Vector2::new(-heading.y, heading.x);
527    let Ok(across) = across.normalized(tol) else {
528        ogeom_bail!(
529            Construction,
530            "the edge's trim stands still at its middle, so the face's side of it is unknown"
531        );
532    };
533    let mut outward = None;
534    for step in [1e-2, 1e-3, 1e-4] {
535        let h = across * (step * size);
536        let (left, right) = (
537            ogeom_mesh::inside_boundary(&rings, uv + h),
538            ogeom_mesh::inside_boundary(&rings, uv - h),
539        );
540        if left != right {
541            outward = Some(if left { -1.0 } else { 1.0 });
542            break;
543        }
544    }
545    let Some(outward) = outward else {
546        ogeom_bail!(
547            Construction,
548            "the face's side of the edge cannot be told from its boundary"
549        );
550    };
551    let (du, dv) = side.surface.d1_at(uv.x, uv.y, tol)?;
552    let off_chart = (du * across.x + dv * across.y) * outward;
553    let t = (side.range.0 + side.range.1) * 0.5;
554    let normal = face_normal(&side.surface, uv, tol)?;
555    let crossed = normal.cross(side.curve.d1_at(t, tol)?);
556    let agree = crossed.dot(off_chart);
557    if agree.abs() <= f64::EPSILON * crossed.magnitude() * off_chart.magnitude() {
558        ogeom_bail!(
559            Construction,
560            "the face's side of the edge cannot be told from its boundary"
561        );
562    }
563    Ok(agree.signum())
564}
565
566/// The unit normal of a face's surface at a point of the edge.
567fn face_normal(surface: &SurfaceGeometry, uv: Point2, tol: Tolerances) -> OgeomResult<Vector> {
568    match surface.normal_at(uv.x, uv.y, tol) {
569        Ok(n) => Ok(n.vector()),
570        Err(_) => ogeom_bail!(
571            Construction,
572            "the face has no normal at a point of the edge, so there is no tangent plane to \
573             meet"
574        ),
575    }
576}
577
578impl Side {
579    /// The edge's parameter and its trim's at the blend's `u = s`.
580    fn parameters(&self, s: f64) -> (f64, f64) {
581        let f = if self.reversed { 1.0 - s } else { s };
582        let (t0, t1) = self.range;
583        let (p0, p1) = self.trim.1;
584        ((t1 - t0).mul_add(f, t0), (p1 - p0).mul_add(f, p0))
585    }
586
587    /// The face's frame at the blend's `u = s`.
588    fn station(&self, s: f64, tol: Tolerances) -> OgeomResult<Station> {
589        let (t, p) = self.parameters(s);
590        let uv = self.trim.0.point_at(p, tol)?;
591        let normal = face_normal(&self.surface, uv, tol)?;
592        let along = self.curve.d1_at(t, tol)?;
593        let Ok(off) = (normal.cross(along) * self.off).normalized(tol) else {
594            ogeom_bail!(
595                Construction,
596                "the edge stands still at a point, so it has no direction to blend square to"
597            );
598        };
599        let bend = if self.order >= 2 {
600            let Ok(curvature) = self.surface.curvature_at(uv.x, uv.y, tol) else {
601                ogeom_bail!(
602                    Construction,
603                    "the face has no curvature at a point of the edge to match"
604                );
605            };
606            let Some(k) = curvature.normal_curvature(off) else {
607                ogeom_bail!(
608                    Construction,
609                    "the face has no curvature at a point of the edge to match"
610                );
611            };
612            curvature.normal.vector() * k
613        } else {
614            Vector::ZERO
615        };
616        Ok(Station {
617            normal,
618            off,
619            bend,
620            along,
621        })
622    }
623}
624
625/// The derivatives one side of a Hermite column holds.
626struct Hermite {
627    point: Point,
628    /// The unit direction the column leaves `point` in.
629    out: Vector,
630    /// The second derivative of a unit-speed crossing curve at `point`.
631    bend: Vector,
632    order: usize,
633}
634
635impl Hermite {
636    /// The Bézier control points from `self` to `far`, of the least degree
637    /// matching both sides' derivatives, the crossing tangent `width` long.
638    fn column(&self, far: &Self, width: f64) -> Vec<Point> {
639        let n = self.order + far.order + 1;
640        #[expect(clippy::cast_precision_loss, reason = "a degree of at most five")]
641        let (n1, n2) = (n as f64, (n * n.saturating_sub(1)) as f64);
642        let mut rows = vec![Point::ORIGIN; n + 1];
643        rows[0] = self.point;
644        rows[n] = far.point;
645        for (side, at, step) in [(self, 0usize, 1isize), (far, n, -1isize)] {
646            let index = |k: isize| at.wrapping_add_signed(step * k);
647            if side.order >= 1 {
648                rows[index(1)] = side.point + side.out * (width / n1);
649            }
650            if side.order >= 2 {
651                rows[index(2)] = rows[index(1)]
652                    + (rows[index(1)] - side.point)
653                    + side.bend * (width * width / n2);
654            }
655        }
656        rows
657    }
658}
659
660/// Give two B-splines on `[0, 1]` the same degree and knots without moving
661/// either. Knots closer than the parametric tolerance are one knot.
662fn compatible(a: &mut BSplineCurve, b: &mut BSplineCurve, tol: Tolerances) -> OgeomResult<()> {
663    while a.degree() < b.degree() {
664        *a = a.elevated(tol)?;
665    }
666    while b.degree() < a.degree() {
667        *b = b.elevated(tol)?;
668    }
669    let merge = |into: &BSplineCurve, from: &BSplineCurve| -> OgeomResult<BSplineCurve> {
670        let mut out = into.clone();
671        for (k, m) in from.knots().distinct() {
672            let have = out.knots().distinct();
673            let (value, held) = have
674                .iter()
675                .find(|(h, _)| (h - k).abs() <= tol.parametric())
676                .map_or((k, 0), |(h, c)| (*h, *c));
677            if held < m {
678                out = out.with_knot_inserted(value, m - held, tol)?;
679            }
680        }
681        Ok(out)
682    };
683    *b = merge(b, a)?;
684    *a = merge(a, b)?;
685    let (ka, kb) = (a.knots().knots(), b.knots().knots());
686    if ka.len() != kb.len()
687        || ka
688            .iter()
689            .zip(kb)
690            .any(|(x, y)| (x - y).abs() > tol.parametric())
691    {
692        ogeom_bail!(
693            Invariant,
694            "the two edges' B-spline forms did not come to share their knots"
695        );
696    }
697    Ok(())
698}
699
700/// Halve every knot span of both B-splines.
701fn refine(a: &mut BSplineCurve, b: &mut BSplineCurve, tol: Tolerances) -> OgeomResult<()> {
702    let distinct = a.knots().distinct();
703    for pair in distinct.windows(2) {
704        let middle = f64::midpoint(pair[0].0, pair[1].0);
705        *a = a.with_knot_inserted(middle, 1, tol)?;
706        *b = b.with_knot_inserted(middle, 1, tol)?;
707    }
708    Ok(())
709}
710
711/// The Greville abscissae of a knot vector: where each control point
712/// stands along the curve.
713fn greville(knots: &KnotVector) -> Vec<f64> {
714    let p = knots.degree();
715    let k = knots.knots();
716    #[expect(
717        clippy::cast_precision_loss,
718        reason = "a degree, far below the mantissa"
719    )]
720    let scale = 1.0 / p as f64;
721    (0..knots.control_point_count())
722        .map(|i| k[i + 1..=i + p].iter().sum::<f64>() * scale)
723        .collect()
724}
725
726/// The blend patch: the two splines' control polygons as its outer rows,
727/// each column a Bézier across the gap.
728///
729/// Next to each edge, the differences between the rows are the control
730/// vectors of the crossing derivatives, which share the edge's weights, so
731/// each derivative along the edge is a B-spline in its own right. They are
732/// interpolated at the Greville abscissae: the first to the direction off
733/// the face, the gap's width long, and the second to the face's normal
734/// curvature along it.
735fn hermite(near: &Side, far: &Side, tol: Tolerances) -> OgeomResult<BSplineSurface> {
736    let n = near.order + far.order + 1;
737    let knots = near.spline.knots().clone();
738    let abscissae = greville(&knots);
739    let (ca, cb) = (near.spline.control_points(), far.spline.control_points());
740    let count = ca.len();
741    let mut widths = Vec::with_capacity(count);
742    for s in &abscissae {
743        widths.push(
744            near.spline
745                .point_at(*s, tol)?
746                .distance(far.spline.point_at(*s, tol)?),
747        );
748    }
749    #[expect(clippy::cast_precision_loss, reason = "a degree of at most five")]
750    let (n1, n2) = (n as f64, (n * (n - 1)) as f64);
751    // Per side, the control vectors of the first and second derivatives
752    // across, each divided by the factor the Bézier form puts on it.
753    let crossing = |side: &Side, control: &[Weighted<Point>]| -> OgeomResult<[Vec<Vector>; 2]> {
754        if side.order == 0 {
755            return Ok([vec![Vector::ZERO; count], vec![Vector::ZERO; count]]);
756        }
757        let mut first = Vec::with_capacity(count);
758        let mut second = Vec::with_capacity(count);
759        for (s, width) in abscissae.iter().zip(&widths) {
760            let station = side.station(*s, tol)?;
761            first.push(station.off * (width / n1));
762            second.push(if side.order >= 2 {
763                station.bend * (width * width / n2)
764            } else {
765                Vector::ZERO
766            });
767        }
768        let weights: Vec<f64> = control.iter().map(|c| c.weight).collect();
769        let matrix = collocation(&knots, &weights, &abscissae, tol)?;
770        Ok([solve(&matrix, &first)?, solve(&matrix, &second)?])
771    };
772    let [near_first, near_second] = crossing(near, ca)?;
773    let [far_first, far_second] = crossing(far, cb)?;
774    let mut grid = Vec::with_capacity(count * (n + 1));
775    for i in 0..count {
776        let (pa, pb) = (ca[i].point(), cb[i].point());
777        let mut column = vec![Point::ORIGIN; n + 1];
778        column[0] = pa;
779        column[n] = pb;
780        if near.order >= 1 {
781            column[1] = pa + near_first[i];
782        }
783        if near.order >= 2 {
784            column[2] = pa + near_first[i] * 2.0 + near_second[i];
785        }
786        if far.order >= 1 {
787            column[n - 1] = pb + far_first[i];
788        }
789        if far.order >= 2 {
790            column[n - 2] = pb + far_first[i] * 2.0 + far_second[i];
791        }
792        for (j, point) in column.into_iter().enumerate() {
793            // The rows a side's derivatives are read from share its
794            // weights, so the weight function is flat across the edge.
795            let weight = if j <= near.order {
796                ca[i].weight
797            } else {
798                cb[i].weight
799            };
800            grid.push(Weighted::new(point, weight, tol)?);
801        }
802    }
803    let mut v_knots = vec![0.0; n + 1];
804    v_knots.extend(std::iter::repeat_n(1.0, n + 1));
805    BSplineSurface::rational(
806        knots,
807        KnotVector::new(v_knots, n)?,
808        ControlGrid::new(grid, count, n + 1)?,
809    )
810}
811
812/// The rational basis functions with `weights` over `knots`, row `k` read
813/// at `at[k]`.
814fn collocation(
815    knots: &KnotVector,
816    weights: &[f64],
817    at: &[f64],
818    tol: Tolerances,
819) -> OgeomResult<Vec<Vec<f64>>> {
820    let p = knots.degree();
821    let mut rows = Vec::with_capacity(at.len());
822    for u in at {
823        let span = knots.span(*u, tol)?;
824        let basis = knots.basis(span, *u);
825        let mut row = vec![0.0; weights.len()];
826        let mut total = 0.0;
827        for (r, value) in basis.iter().enumerate() {
828            let i = span - p + r;
829            row[i] = value * weights[i];
830            total += row[i];
831        }
832        for value in &mut row {
833            *value /= total;
834        }
835        rows.push(row);
836    }
837    Ok(rows)
838}
839
840/// `matrix · x = rhs` for vectors `x`, by elimination with partial
841/// pivoting.
842fn solve(matrix: &[Vec<f64>], rhs: &[Vector]) -> OgeomResult<Vec<Vector>> {
843    let size = rhs.len();
844    let mut a: Vec<Vec<f64>> = matrix.to_vec();
845    let mut b: Vec<Vector> = rhs.to_vec();
846    for col in 0..size {
847        let pivot = (col..size)
848            .max_by(|x, y| a[*x][col].abs().total_cmp(&a[*y][col].abs()))
849            .unwrap_or(col);
850        if a[pivot][col].abs() <= f64::EPSILON {
851            ogeom_bail!(
852                Numeric,
853                "the crossing derivatives cannot be interpolated along the edge"
854            );
855        }
856        a.swap(col, pivot);
857        b.swap(col, pivot);
858        let (above, below) = a.split_at_mut(col + 1);
859        let leading = &above[col];
860        for (offset, target) in below.iter_mut().enumerate() {
861            let factor = target[col] / leading[col];
862            if factor == 0.0 {
863                continue;
864            }
865            for (t, l) in target[col..].iter_mut().zip(&leading[col..]) {
866                *t -= factor * l;
867            }
868            let pivot_value = b[col];
869            b[col + 1 + offset] -= pivot_value * factor;
870        }
871    }
872    let mut x = vec![Vector::ZERO; size];
873    for row in (0..size).rev() {
874        let mut sum = b[row];
875        for k in row + 1..size {
876            sum -= x[k] * a[row][k];
877        }
878        x[row] = sum * (1.0 / a[row][row]);
879    }
880    Ok(x)
881}
882
883/// The worst join along the edge at `v`: the largest angle between the two
884/// normals, and the largest difference of normal curvatures square to the
885/// edge times the gap's width there. Each is zero where its side does not
886/// ask for it.
887fn measure(
888    patch: &BSplineSurface,
889    near: &Side,
890    far: &Side,
891    v: f64,
892    tol: Tolerances,
893) -> OgeomResult<(f64, f64)> {
894    let side = if v == 0.0 { near } else { far };
895    if side.order == 0 {
896        return Ok((0.0, 0.0));
897    }
898    let (mut angle, mut bend) = (0.0f64, 0.0f64);
899    let knots = side.spline.knots().distinct();
900    for pair in knots.windows(2) {
901        for k in 0..=STATIONS_PER_SPAN {
902            #[expect(clippy::cast_precision_loss, reason = "a station index")]
903            let f = k as f64 / STATIONS_PER_SPAN as f64;
904            let s = (pair[1].0 - pair[0].0).mul_add(f, pair[0].0);
905            let station = side.station(s, tol)?;
906            let Ok(blend_normal) = patch.normal_at(s, v, tol) else {
907                ogeom_bail!(
908                    Construction,
909                    "the blend folds onto the edge, so it has no tangent plane there"
910                );
911            };
912            let blend_normal = blend_normal.vector();
913            let along = blend_normal.dot(station.normal);
914            angle = angle.max(along.abs().min(1.0).acos());
915            if side.order >= 2 {
916                let width = near
917                    .spline
918                    .point_at(s, tol)?
919                    .distance(far.spline.point_at(s, tol)?);
920                let Ok(curvature) = patch.curvature_at(s, v, tol) else {
921                    ogeom_bail!(
922                        Construction,
923                        "the blend has no curvature at a point of the edge to match"
924                    );
925                };
926                let kf = station.bend.dot(station.normal);
927                let Some(kb) =
928                    curvature.normal_curvature(curvature.normal.vector().cross(station.along))
929                else {
930                    ogeom_bail!(
931                        Construction,
932                        "the blend has no curvature at a point of the edge to match"
933                    );
934                };
935                let kb = if curvature.normal.vector().dot(station.normal) < 0.0 {
936                    -kb
937                } else {
938                    kb
939                };
940                bend = bend.max((kb - kf).abs() * width);
941            }
942        }
943    }
944    Ok((angle, bend))
945}
946
947/// A new side edge along the patch's parameter line `u = at`, from the
948/// first edge's end to the second's, trimmed on the patch.
949fn side_edge(
950    model: &mut Model,
951    curve: Curve,
952    at: f64,
953    (from, to): (&Shape, &Shape),
954    surface: ogeom_topo::SurfaceId,
955    tol: Tolerances,
956) -> OgeomResult<Shape> {
957    let edge = make_edge_between(model, curve, (0.0, 1.0), from, to, tol)?.shape;
958    let trim = Line2d::over(
959        Axis2 {
960            location: Point2::new(at, 0.0),
961            direction: Direction2::new(Vector2::new(0.0, 1.0), tol)?,
962        },
963        0.0,
964        1.0,
965    )?;
966    attach_pcurve(
967        model,
968        &edge,
969        trim.into(),
970        surface,
971        Location::identity(),
972        (0.0, 1.0),
973    )?;
974    if let Some(NodeData::Edge(data)) = model.node_mut(&edge).map(|n| n.data_mut()) {
975        data.assert_same_parameter(true);
976    }
977    Ok(edge)
978}
979
980/// The end `end` of `edge`, read for a blend curve: its vertex, and the
981/// derivatives the curve matches there to `order`, leaving along the
982/// direction that continues the edge past its end.
983fn joint(
984    model: &Model,
985    edge: &Shape,
986    end: End,
987    order: usize,
988    tol: Tolerances,
989) -> OgeomResult<(Shape, Hermite)> {
990    if model.kind_of(edge)? != ShapeType::Edge {
991        ogeom_bail!(
992            Construction,
993            "a blend curve bridges the ends of edges; got a {:?}",
994            model.kind_of(edge)?
995        );
996    }
997    if !edge.location().is_identity() {
998        ogeom_bail!(
999            Construction,
1000            "a placed edge is not blended; bake its placement into its geometry first"
1001        );
1002    }
1003    let Some(NodeData::Edge(data)) = model.node(edge).map(|n| n.data()) else {
1004        ogeom_bail!(Dangling, "edge is not in this model");
1005    };
1006    if data.degenerate {
1007        ogeom_bail!(Construction, "a degenerate edge has no tangent to continue");
1008    }
1009    let Some(EdgeRepr::Curve3d {
1010        curve,
1011        location,
1012        range,
1013    }) = data.curve3d()
1014    else {
1015        ogeom_bail!(Construction, "the edge has no curve in space");
1016    };
1017    if !location.is_identity() {
1018        ogeom_bail!(
1019            Construction,
1020            "a placed edge is not blended; bake its placement into its geometry first"
1021        );
1022    }
1023    let Some(curve) = model.geometry().curve(*curve) else {
1024        ogeom_bail!(Dangling, "curve is not in this model");
1025    };
1026    let Some((start, finish)) = edge_vertices(model, edge)? else {
1027        ogeom_bail!(Construction, "the edge has no vertices");
1028    };
1029    let backwards = edge.orientation() == Orientation::Reversed;
1030    // At the curve's range end the edge continues forward along its
1031    // parameter; at the range start it continues backward.
1032    let (vertex, at_range_end) = match end {
1033        End::Start => (start, backwards),
1034        End::End => (finish, !backwards),
1035    };
1036    let t = if at_range_end { range.1 } else { range.0 };
1037    let d = curve.derivatives_at(t, 2, tol)?;
1038    let speed = d[1].magnitude();
1039    let Ok(tangent) = Direction::new(d[1], tol) else {
1040        ogeom_bail!(
1041            Construction,
1042            "the edge stands still at that end, so it has no tangent to continue"
1043        );
1044    };
1045    let tangent = tangent.vector();
1046    let bend = (d[2] - tangent * d[2].dot(tangent)) * (1.0 / (speed * speed));
1047    Ok((
1048        vertex,
1049        Hermite {
1050            point: curve.point_at(t, tol)?,
1051            out: if at_range_end { tangent } else { -tangent },
1052            bend,
1053            order,
1054        },
1055    ))
1056}