Skip to main content

ogeom_algo/
extend.rs

1//! Growing a face past one of its boundary edges.
2//!
3//! The face is rebuilt with the chosen edge replaced by three new ones: a
4//! side up from each of its ends and a far edge across, `length` beyond it.
5//! Every other edge of the face is the same edge node as before, so the
6//! neighbours that share them are untouched; the chosen edge itself stays
7//! in the model for the neighbour across it.
8//!
9//! A plane grows square to a straight edge in its own metric. On the other
10//! surfaces the edge must run along a parameter line, and the face grows
11//! along the crossing parameter: an analytic surface on its own equation, a
12//! B-spline patch continued past the side of its domain the edge lies on.
13
14use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
15use ogeom_geom::{
16    BSplineSurface, CircleCurve, ConeSurface, Continuity, Curve, Curve2d as _, Curve3d as _,
17    CylinderSurface, Line2d, LineCurve, PlanarCurve, PlaneSurface, Surface as _, SurfaceGeometry,
18};
19use ogeom_math::{Axis, Axis2, Circle, Direction, Direction2, Frame, Point, Point2, Vector2};
20use ogeom_topo::{
21    EdgeRepr, FaceData, Location, Model, NodeData, Orientation, Shape, ShapeType, SurfaceId,
22};
23
24use crate::build::{attach_pcurve, edge_vertices, make_edge_between, make_wire};
25use crate::history::{Built, History};
26
27/// How a face continues past the edge it is extended across.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Extension {
30    /// The surface continues as itself: an analytic surface along its own
31    /// equation, a B-spline patch as the polynomial of the span at the edge,
32    /// which is as smooth across the old edge as the patch is anywhere.
33    Natural,
34    /// The surface continues as the polynomial of its derivatives across the
35    /// edge, carried to the least order that gives the join `continuity`:
36    /// the first order (straight out along the crossing tangent) for
37    /// [`Continuity::C0`], [`Continuity::G1`] and [`Continuity::C1`], the
38    /// second for [`Continuity::G2`] and [`Continuity::C2`]. An analytic
39    /// surface takes it only where its crossing parameter line is straight
40    /// (a plane, a cylinder or cone grown along its rulings), where it is
41    /// the surface itself.
42    Linear {
43        /// The smoothness the join across the old edge keeps.
44        continuity: Continuity,
45    },
46}
47
48/// Grow the face `shape` past its boundary edge `edge` by `length`.
49///
50/// The length is measured along the surface, square to the edge: on a
51/// plane every point of the edge moves that far, square to it; elsewhere it
52/// is the arc length of the crossing parameter line through the edge's
53/// middle, which is square to the edge on every analytic surface. The
54/// result is a new face on the same surface (an analytic one with its
55/// domain widened where the extension needs it) or on the patch continued
56/// as `mode` says. The edges the face kept are the same nodes, carrying a
57/// trim on the new surface where it is a new one.
58///
59/// A placed face, or a face with placed edges (a prism's far end edges are
60/// its profile's edges under the prism's translation), is first rebuilt
61/// with its placements baked in, and that rebuilt face is extended: its
62/// edges are new nodes on the same geometry, which sew to the old ones.
63///
64/// History: the face is modified into the new face; `edge` is modified into
65/// the far edge and generates the two sides; where the face was baked
66/// first, every other edge is modified into its baked twin.
67///
68/// # Errors
69///
70/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction), by name,
71/// where:
72///
73/// - `length` is not positive and finite;
74/// - `shape` is not a face, or `edge` is not an edge of its outer boundary
75///   (an edge on no boundary of it, an edge bounding a hole, a seam the
76///   boundary runs along twice, a degenerate edge);
77/// - the surface is neither a plane, a cylinder, a cone, a sphere, a torus
78///   nor a B-spline patch;
79/// - on a plane, the edge is not straight;
80/// - elsewhere, the edge does not run along a parameter line, has no trim on
81///   the surface, or the face's boundary cannot be walked in its chart;
82/// - the extension would wrap the face round a periodic direction onto
83///   itself, or reach a cone's apex or a sphere's pole;
84/// - a B-spline edge is not on a side of the patch's domain, or the patch
85///   closes on itself across it;
86/// - a linear extension asks [`Continuity::CInfinity`], or crosses a curved
87///   parameter line of an analytic surface.
88pub fn extend_face(
89    model: &mut Model,
90    shape: &Shape,
91    edge: &Shape,
92    length: f64,
93    mode: Extension,
94    tol: Tolerances,
95) -> OgeomResult<Built> {
96    if !(length > 0.0 && length.is_finite()) {
97        ogeom_bail!(
98            Construction,
99            "a face is extended by a positive length; got {length}"
100        );
101    }
102    let order = order_of(mode)?;
103    let kind = model.kind_of(shape)?;
104    if kind != ShapeType::Face {
105        ogeom_bail!(Construction, "extend_face extends a face; got a {kind:?}");
106    }
107    if model.kind_of(edge)? != ShapeType::Edge {
108        ogeom_bail!(Construction, "a face is extended across one of its edges");
109    }
110    let baked = crate::convert::baked_where_placed(model, shape, tol)?;
111    if !baked.history.is_empty() {
112        let Some(twin) = baked.history.modified(edge).first().cloned() else {
113            ogeom_bail!(Construction, "the edge is not on the face");
114        };
115        let grown = extend_face(model, &baked.shape, &twin, length, mode, tol)?;
116        return Ok(Built::new(grown.shape, baked.history.then(&grown.history)));
117    }
118    let (surface_id, face_tolerance) = {
119        let Some(NodeData::Face(data)) = model.node(shape).map(|n| n.data()) else {
120            ogeom_bail!(Dangling, "face is not in this model");
121        };
122        (data.surface, data.tolerance)
123    };
124
125    // The face's wires and edges as stored, so the rebuilt face walks its
126    // boundary exactly as the old one did.
127    let wires = model.children_of(&shape.oriented(Orientation::Forward))?;
128    let Some(outer) = wires.first().cloned() else {
129        ogeom_bail!(
130            Construction,
131            "the face has no boundary, so no edge to extend it across"
132        );
133    };
134    let mut loops: Vec<Vec<Shape>> = Vec::with_capacity(wires.len());
135    for wire in &wires {
136        loops.push(model.children_of(&wire.oriented(Orientation::Forward))?);
137    }
138    let hits: Vec<usize> = loops[0]
139        .iter()
140        .enumerate()
141        .filter(|(_, e)| e.is_same(edge))
142        .map(|(i, _)| i)
143        .collect();
144    let at = match hits.as_slice() {
145        [i] => *i,
146        [] if loops[1..].iter().flatten().any(|e| e.is_same(edge)) => ogeom_bail!(
147            Construction,
148            "the edge bounds a hole in the face; a face is extended across its outer boundary"
149        ),
150        [] => ogeom_bail!(Construction, "the edge is not on the face"),
151        _ => ogeom_bail!(
152            Construction,
153            "the face's boundary runs along the edge twice (a seam); there is no one side \
154             to extend it across"
155        ),
156    };
157    let crossed = loops[0][at].clone();
158    let Some(NodeData::Edge(edge_data)) = model.node(&crossed).map(|n| n.data()) else {
159        ogeom_bail!(Dangling, "edge is not in this model");
160    };
161    if edge_data.degenerate {
162        ogeom_bail!(
163            Construction,
164            "a degenerate edge has no extent to extend the face across"
165        );
166    }
167    let Some((from_vertex, to_vertex)) = edge_vertices(model, &crossed)? else {
168        ogeom_bail!(Construction, "the edge has no vertices");
169    };
170    let Some(surface) = model.geometry().surface(surface_id).cloned() else {
171        ogeom_bail!(Dangling, "face refers to a surface not in this model");
172    };
173
174    let plan = match &surface {
175        SurfaceGeometry::Plane(plane) => plan_on_plane(
176            model,
177            *plane,
178            &loops[0],
179            &crossed,
180            &from_vertex,
181            &to_vertex,
182            length,
183            tol,
184        )?,
185        SurfaceGeometry::Cylinder(_)
186        | SurfaceGeometry::Cone(_)
187        | SurfaceGeometry::Sphere(_)
188        | SurfaceGeometry::Torus(_)
189        | SurfaceGeometry::BSpline(_) => plan_on_chart(
190            model, shape, &surface, surface_id, &crossed, length, order, tol,
191        )?,
192        other => ogeom_bail!(
193            Construction,
194            "a face on a {:?} surface is not extended; only planes, cylinders, cones, \
195             spheres, tori and B-spline patches are",
196            other.kind()
197        ),
198    };
199
200    let new_surface_id = match plan.surface {
201        Some(widened) => model.geometry_mut().add_surface(widened),
202        None => surface_id,
203    };
204    let Some(target) = model.geometry().surface(new_surface_id).cloned() else {
205        ogeom_bail!(Dangling, "the extended surface is not in this model");
206    };
207    if new_surface_id != surface_id {
208        let kept: Vec<Shape> = loops
209            .iter()
210            .flatten()
211            .filter(|e| !e.is_same(&crossed))
212            .cloned()
213            .collect();
214        retrim(model, &kept, surface_id, new_surface_id)?;
215    }
216
217    let far_from = model.add_point(target.point_at(plan.far.0.x, plan.far.0.y, tol)?);
218    let far_to = model.add_point(target.point_at(plan.far.1.x, plan.far.1.y, tol)?);
219    let side_a = chart_edge(
220        model,
221        &target,
222        new_surface_id,
223        plan.flat,
224        (plan.near.0, &from_vertex),
225        (plan.far.0, &far_from),
226        tol,
227    )?;
228    let far = chart_edge(
229        model,
230        &target,
231        new_surface_id,
232        plan.flat,
233        (plan.far.0, &far_from),
234        (plan.far.1, &far_to),
235        tol,
236    )?;
237    let side_b = chart_edge(
238        model,
239        &target,
240        new_surface_id,
241        plan.flat,
242        (plan.far.1, &far_to),
243        (plan.near.1, &to_vertex),
244        tol,
245    )?;
246
247    let mut ring = loops[0].clone();
248    ring.splice(at..=at, [side_a.clone(), far.clone(), side_b.clone()]);
249    let new_outer = make_wire(model, &ring, tol)?
250        .shape
251        .oriented(outer.orientation());
252    let mut new_wires = vec![new_outer];
253    new_wires.extend(wires[1..].iter().cloned());
254    let mut data = FaceData::new(new_surface_id, Location::identity());
255    data.tolerance = face_tolerance;
256    let face = model
257        .add_face(data, &new_wires)?
258        .oriented(shape.orientation());
259
260    let mut history = History::new();
261    history.modify(shape, face.clone());
262    history.modify(edge, far.oriented(Orientation::Forward));
263    history.generate(edge, side_a.oriented(Orientation::Forward));
264    history.generate(edge, side_b.oriented(Orientation::Forward));
265    Ok(Built::new(face, history))
266}
267
268/// The order of derivatives a mode carries across the edge; `None` for all
269/// of them.
270fn order_of(mode: Extension) -> OgeomResult<Option<usize>> {
271    match mode {
272        Extension::Natural => Ok(None),
273        Extension::Linear { continuity } => match continuity {
274            Continuity::C0 | Continuity::G1 | Continuity::C1 => Ok(Some(1)),
275            Continuity::G2 | Continuity::C2 => Ok(Some(2)),
276            Continuity::CInfinity => ogeom_bail!(
277                Construction,
278                "a linear extension carries derivatives to the second order at most; \
279                 a join smooth to every order is the natural extension"
280            ),
281        },
282    }
283}
284
285/// Where the new edges go, in the chart of the surface the face ends up on.
286struct Plan {
287    /// The surface to build on, where it is not the face's own.
288    surface: Option<SurfaceGeometry>,
289    /// Whether the chart is a plane's, where the new edges are straight
290    /// between any two chart points.
291    flat: bool,
292    /// The crossed edge's ends, in the order the face's boundary walks it.
293    near: (Point2, Point2),
294    /// Where each end goes.
295    far: (Point2, Point2),
296}
297
298/// A plane's extension: the straight edge moved square to itself, away
299/// from the face, in the plane's own isometric chart.
300#[allow(clippy::too_many_arguments)]
301fn plan_on_plane(
302    model: &Model,
303    plane: PlaneSurface,
304    outer: &[Shape],
305    crossed: &Shape,
306    from_vertex: &Shape,
307    to_vertex: &Shape,
308    length: f64,
309    tol: Tolerances,
310) -> OgeomResult<Plan> {
311    let frame = plane.plane().frame();
312    let chart = |p: Point| frame.to_local(p).xy();
313    let a = vertex_point(model, from_vertex)?;
314    let b = vertex_point(model, to_vertex)?;
315    let chord = b - a;
316    let span = chord.magnitude();
317    if span <= tol.confusion() {
318        ogeom_bail!(
319            Construction,
320            "the edge closes on itself; there is no straight run to extend across"
321        );
322    }
323    for p in edge_samples(model, crossed, 8, tol)? {
324        let off = (p - a).cross(chord).magnitude() / span;
325        if off > tol.confusion() {
326            ogeom_bail!(
327                Construction,
328                "a planar face is extended across a straight edge; this one strays {off} \
329                 from its chord"
330            );
331        }
332    }
333    // The outer boundary as a polygon in the chart: its winding says which
334    // side of the edge the face is on.
335    let mut polygon: Vec<Point2> = Vec::new();
336    for e in outer {
337        let mut samples = edge_samples(model, e, 8, tol)?;
338        if e.orientation() == Orientation::Reversed {
339            samples.reverse();
340        }
341        samples.pop();
342        polygon.extend(samples.into_iter().map(chart));
343    }
344    // Summed about the polygon's first point: the same sum as about the
345    // chart's origin, which may stand far off, where it is a difference of
346    // huge products.
347    let mut area = 0.0;
348    if let Some(&anchor) = polygon.first() {
349        for (i, p) in polygon.iter().enumerate() {
350            let q = polygon[(i + 1) % polygon.len()];
351            area += (*p - anchor).cross(q - anchor);
352        }
353    }
354    let (pa, pb) = (chart(a), chart(b));
355    let along = (pb - pa).normalized(tol)?;
356    // The face lies to the left of a counter-clockwise boundary.
357    let outward = if area > 0.0 {
358        -along.perpendicular()
359    } else {
360        along.perpendicular()
361    };
362    let (qa, qb) = (pa + outward * length, pb + outward * length);
363    let ((ua, ub), (va, vb)) = plane.domain();
364    let inside = |p: Point2| p.x >= ua && p.x <= ub && p.y >= va && p.y <= vb;
365    let surface = if inside(qa) && inside(qb) {
366        None
367    } else {
368        let lo = (ua.min(qa.x).min(qb.x), va.min(qa.y).min(qb.y));
369        let hi = (ub.max(qa.x).max(qb.x), vb.max(qa.y).max(qb.y));
370        Some(SurfaceGeometry::Plane(PlaneSurface::over(
371            plane.plane(),
372            (lo.0, hi.0),
373            (lo.1, hi.1),
374        )?))
375    };
376    Ok(Plan {
377        surface,
378        flat: true,
379        near: (pa, pb),
380        far: (qa, qb),
381    })
382}
383
384/// An extension along a crossing parameter line: the edge must run along
385/// the other parameter, and the face grows across it.
386#[allow(clippy::too_many_arguments)]
387fn plan_on_chart(
388    model: &Model,
389    face: &Shape,
390    surface: &SurfaceGeometry,
391    surface_id: SurfaceId,
392    crossed: &Shape,
393    length: f64,
394    order: Option<usize>,
395    tol: Tolerances,
396) -> OgeomResult<Plan> {
397    let Some(NodeData::Edge(data)) = model.node(crossed).map(|n| n.data()) else {
398        ogeom_bail!(Dangling, "edge is not in this model");
399    };
400    let (pcurve, range) = match data.pcurve_for(surface_id, crossed.location()) {
401        Some(EdgeRepr::PCurve { curve, range, .. }) => (*curve, *range),
402        Some(EdgeRepr::Seam { .. }) => ogeom_bail!(
403            Construction,
404            "the edge is a seam of the surface; there is no one side to extend the face across"
405        ),
406        _ => ogeom_bail!(
407            Construction,
408            "the edge has no trim on the face's surface, so where it runs in the chart is unknown"
409        ),
410    };
411    let Some(pcurve) = model.geometry().pcurve(pcurve) else {
412        ogeom_bail!(Dangling, "pcurve is not in this model");
413    };
414    let samples: Vec<Point2> = (0..=8)
415        .map(|k| pcurve.point_at(range.0 + (range.1 - range.0) * f64::from(k) / 8.0, tol))
416        .collect::<OgeomResult<_>>()?;
417    let (first, last) = (samples[0], samples[8]);
418    let (near_from, near_to) = if crossed.orientation() == Orientation::Reversed {
419        (last, first)
420    } else {
421        (first, last)
422    };
423    let slack = tol.parametric();
424    let still = |pick: fn(Point2) -> f64| {
425        samples
426            .iter()
427            .all(|p| (pick(*p) - pick(first)).abs() <= slack)
428    };
429    // `across_u`: the edge holds `u` and the face grows in `u`.
430    let across_u = if still(|p| p.x) {
431        true
432    } else if still(|p| p.y) {
433        false
434    } else {
435        ogeom_bail!(
436            Construction,
437            "the edge does not run along a parameter line of the surface, so there is no \
438             crossing parameter to extend the face along"
439        );
440    };
441    let cross = |p: Point2| if across_u { p.x } else { p.y };
442    let along = |p: Point2| if across_u { p.y } else { p.x };
443    let place = |c: f64, a: f64| {
444        if across_u {
445            Point2::new(c, a)
446        } else {
447            Point2::new(a, c)
448        }
449    };
450    let c = cross(first);
451
452    // Which way is out: away from the side the face lies on.
453    let Some(sides) = crate::mass_chart::material_sides(model, face, tol) else {
454        ogeom_bail!(
455            Construction,
456            "the face's boundary cannot be walked in its chart, so its side of the edge is unknown"
457        );
458    };
459    let mut inward = 0.0;
460    let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
461    for (e, p, d) in &sides {
462        lo = lo.min(cross(*p));
463        hi = hi.max(cross(*p));
464        if e.is_same(crossed) {
465            inward += cross(Point2::from_vector(*d));
466        }
467    }
468    if inward == 0.0 {
469        ogeom_bail!(
470            Construction,
471            "the face's side of the edge cannot be told from its boundary walk"
472        );
473    }
474    let outward = -inward.signum();
475    let middle = 0.5 * (along(near_from) + along(near_to));
476
477    let periodic = if across_u {
478        surface.is_periodic_u()
479    } else {
480        surface.is_periodic_v()
481    };
482    let ((ua, ub), (va, vb)) = surface.domain();
483    let (dlo, dhi) = if across_u { (ua, ub) } else { (va, vb) };
484
485    let (target, widened) = if let SurfaceGeometry::BSpline(patch) = surface {
486        let (to, longer) = continue_patch(
487            patch,
488            across_u,
489            outward > 0.0,
490            c,
491            middle,
492            (dlo, dhi),
493            length,
494            order,
495            tol,
496        )?;
497        (to, Some(SurfaceGeometry::BSpline(longer)))
498    } else {
499        let straight = matches!(
500            surface,
501            SurfaceGeometry::Cylinder(_) | SurfaceGeometry::Cone(_)
502        ) && !across_u;
503        if order.is_some() && !straight {
504            ogeom_bail!(
505                Construction,
506                "a linear extension of an analytic surface across a curved parameter line \
507                 would leave the surface; extend it naturally"
508            );
509        }
510        let (du, dv) = surface.d1_at(place(c, middle).x, place(c, middle).y, tol)?;
511        let speed = if across_u { du } else { dv }.magnitude();
512        if speed <= tol.confusion() {
513            ogeom_bail!(
514                Construction,
515                "the surface stands still across the edge; there is no direction to extend in"
516            );
517        }
518        let step = length / speed;
519        let to = c + outward * step;
520        if periodic {
521            let period = dhi - dlo;
522            if hi - lo + step >= period - slack {
523                ogeom_bail!(
524                    Construction,
525                    "the extension would wrap the face round the periodic surface onto itself"
526                );
527            }
528        }
529        (to, widened_analytic(surface, across_u, c, to, slack)?)
530    };
531    Ok(Plan {
532        surface: widened,
533        flat: false,
534        near: (near_from, near_to),
535        far: (
536            place(target, along(near_from)),
537            place(target, along(near_to)),
538        ),
539    })
540}
541
542/// The analytic surface with its domain widened to hold `to` along the
543/// crossing parameter, or `None` where it already does; refused where the
544/// surface ends before it.
545fn widened_analytic(
546    surface: &SurfaceGeometry,
547    across_u: bool,
548    from: f64,
549    to: f64,
550    slack: f64,
551) -> OgeomResult<Option<SurfaceGeometry>> {
552    if across_u {
553        // Every analytic chart here repeats in `u`.
554        return Ok(None);
555    }
556    let ((_, _), (va, vb)) = surface.domain();
557    let wider = (va.min(to), vb.max(to));
558    let holds = to >= va && to <= vb;
559    match surface {
560        SurfaceGeometry::Cylinder(c) => Ok((!holds).then_some(SurfaceGeometry::Cylinder(
561            CylinderSurface::new(c.cylinder(), wider)?,
562        ))),
563        SurfaceGeometry::Cone(c) => {
564            let apex = c.apex_height();
565            if (to - apex) * (from - apex) <= 0.0 || (to - apex).abs() <= slack {
566                ogeom_bail!(
567                    Construction,
568                    "the extension would reach the cone's apex, where the far edge collapses"
569                );
570            }
571            Ok((!holds).then_some(SurfaceGeometry::Cone(ConeSurface::new(c.cone(), wider)?)))
572        }
573        SurfaceGeometry::Sphere(_) => {
574            if to.abs() >= std::f64::consts::FRAC_PI_2 - slack {
575                ogeom_bail!(
576                    Construction,
577                    "the extension would reach the sphere's pole, where the far edge collapses"
578                );
579            }
580            Ok(None)
581        }
582        SurfaceGeometry::Torus(_) => Ok(None),
583        _ => ogeom_bail!(Construction, "not an analytic surface"),
584    }
585}
586
587/// A B-spline patch continued past the side of its domain at `c`, cut back
588/// so its new side lies `length` beyond along the crossing parameter line
589/// at `middle`; with that new side's parameter.
590#[allow(clippy::too_many_arguments)]
591fn continue_patch(
592    patch: &BSplineSurface,
593    across_u: bool,
594    at_end: bool,
595    c: f64,
596    middle: f64,
597    (dlo, dhi): (f64, f64),
598    length: f64,
599    order: Option<usize>,
600    tol: Tolerances,
601) -> OgeomResult<(f64, BSplineSurface)> {
602    let slack = tol.parametric();
603    let side = if at_end { dhi } else { dlo };
604    if (c - side).abs() > slack {
605        ogeom_bail!(
606            Construction,
607            "the edge is not on a side of the patch's domain; a B-spline face is extended \
608             across a side of its patch"
609        );
610    }
611    let closed = if across_u {
612        patch.is_closed_u(tol)
613    } else {
614        patch.is_closed_v(tol)
615    };
616    if closed {
617        ogeom_bail!(
618            Construction,
619            "the patch closes on itself across the edge; continuing it would overlap the face"
620        );
621    }
622    let degree = if across_u {
623        patch.u_knots().degree()
624    } else {
625        patch.v_knots().degree()
626    };
627    let order = order.unwrap_or(degree);
628    // The continuation is the same polynomial whatever its span: grow it
629    // until the crossing line reaches the length, then cut it back there.
630    let mut reach = length;
631    for _ in 0..12 {
632        let longer = patch.extended(across_u, at_end, reach, order, tol)?;
633        let ((ua, ub), (va, vb)) = longer.domain();
634        let (lo, hi) = if across_u { (ua, ub) } else { (va, vb) };
635        let line = Curve::BSpline(if across_u {
636            longer.iso_v_curve(middle, tol)?
637        } else {
638            longer.iso_u_curve(middle, tol)?
639        });
640        let run = if at_end { (c, hi) } else { (lo, c) };
641        let available = crate::length::curve_length(&line, run, tol)?;
642        if available >= length {
643            let to = if at_end {
644                crate::length::parameter_at_length(&line, run, length, tol)?
645            } else {
646                crate::length::parameter_at_length(&line, run, available - length, tol)?
647            };
648            let (u, v) = match (across_u, at_end) {
649                (true, true) => ((ua, to), (va, vb)),
650                (true, false) => ((to, ub), (va, vb)),
651                (false, true) => ((ua, ub), (va, to)),
652                (false, false) => ((ua, ub), (to, vb)),
653            };
654            return Ok((to, longer.segment(u, v, tol)?));
655        }
656        reach *= 2.0;
657    }
658    ogeom_bail!(
659        Construction,
660        "the patch's continuation does not reach the length along the crossing line"
661    )
662}
663
664/// A new edge between two chart points, on the surface: a straight line on
665/// a plane, otherwise the parameter line through both. Its trim on
666/// `surface_id` is attached and checked against its curve, and it comes
667/// back oriented to run `from` to `to`.
668fn chart_edge(
669    model: &mut Model,
670    surface: &SurfaceGeometry,
671    surface_id: SurfaceId,
672    flat: bool,
673    (from, from_vertex): (Point2, &Shape),
674    (to, to_vertex): (Point2, &Shape),
675    tol: Tolerances,
676) -> OgeomResult<Shape> {
677    let (curve, range, pcurve, reversed) = if flat {
678        let a = surface.point_at(from.x, from.y, tol)?;
679        let b = surface.point_at(to.x, to.y, tol)?;
680        let run = from.distance(to);
681        let curve: Curve = LineCurve::new(Axis {
682            location: a,
683            direction: Direction::new(b - a, tol)?,
684        })
685        .into();
686        let pcurve: PlanarCurve = Line2d::over(
687            Axis2 {
688                location: from,
689                direction: Direction2::new(to - from, tol)?,
690            },
691            0.0,
692            run,
693        )?
694        .into();
695        (curve, (0.0, run), pcurve, false)
696    } else {
697        let fixed_u = (from.x - to.x).abs() <= tol.parametric();
698        let (fixed, a, b) = if fixed_u {
699            (from.x, from.y, to.y)
700        } else {
701            (from.y, from.x, to.x)
702        };
703        let (lo, hi) = (a.min(b), a.max(b));
704        let (curve, range, pcurve) = parameter_line(surface, fixed_u, fixed, lo, hi, tol)?;
705        (curve, range, pcurve, a > b)
706    };
707    // The trim follows the curve: checked here, so a wrong chart is an
708    // error and never a face whose edges miss its surface.
709    for k in 0..=4 {
710        let t = range.0 + (range.1 - range.0) * f64::from(k) / 4.0;
711        let on_curve = curve.point_at(t, tol)?;
712        let uv = pcurve.point_at(t, tol)?;
713        let on_surface = surface.point_at(uv.x, uv.y, tol)?;
714        let off = on_curve.distance(on_surface);
715        if off > tol.confusion() {
716            ogeom_bail!(
717                Invariant,
718                "a new edge strays {off} from the surface it was built on"
719            );
720        }
721    }
722    let (start, end) = if reversed {
723        (to_vertex, from_vertex)
724    } else {
725        (from_vertex, to_vertex)
726    };
727    let edge = make_edge_between(model, curve, range, start, end, tol)?.shape;
728    attach_pcurve(
729        model,
730        &edge,
731        pcurve,
732        surface_id,
733        Location::identity(),
734        range,
735    )?;
736    if let Some(NodeData::Edge(data)) = model.node_mut(&edge).map(|n| n.data_mut()) {
737        data.assert_same_parameter(true);
738    }
739    Ok(if reversed { edge.reversed() } else { edge })
740}
741
742/// The parameter line of `surface` at `fixed` (of `u` where `fixed_u`, else
743/// of `v`) from `lo` to `hi` of the other parameter: its curve, the curve's
744/// range, and its trim.
745fn parameter_line(
746    surface: &SurfaceGeometry,
747    fixed_u: bool,
748    fixed: f64,
749    lo: f64,
750    hi: f64,
751    tol: Tolerances,
752) -> OgeomResult<(Curve, (f64, f64), PlanarCurve)> {
753    let place = |p: f64| {
754        if fixed_u {
755            Point2::new(fixed, p)
756        } else {
757            Point2::new(p, fixed)
758        }
759    };
760    let heading = if fixed_u {
761        Direction2::new(Vector2::new(0.0, 1.0), tol)?
762    } else {
763        Direction2::new(Vector2::new(1.0, 0.0), tol)?
764    };
765    if let SurfaceGeometry::BSpline(patch) = surface {
766        // The patch's own iso-curve, parameterized as the chart is.
767        let curve = Curve::BSpline(if fixed_u {
768            patch.iso_u_curve(fixed, tol)?
769        } else {
770            patch.iso_v_curve(fixed, tol)?
771        });
772        let pcurve: PlanarCurve = Line2d::over(
773            Axis2 {
774                location: place(0.0),
775                direction: heading,
776            },
777            lo,
778            hi,
779        )?
780        .into();
781        return Ok((curve, (lo, hi), pcurve));
782    }
783    let start_uv = place(lo);
784    let start = surface.point_at(start_uv.x, start_uv.y, tol)?;
785    let (du, dv) = surface.d1_at(start_uv.x, start_uv.y, tol)?;
786    let tangent = if fixed_u { dv } else { du };
787    let speed = tangent.magnitude();
788    let ruling = matches!(
789        surface,
790        SurfaceGeometry::Cylinder(_) | SurfaceGeometry::Cone(_)
791    ) && fixed_u;
792    // `rate`: the curve's parameter per unit of the chart's.
793    let (curve, rate): (Curve, f64) = if ruling {
794        let line = LineCurve::new(Axis {
795            location: start,
796            direction: Direction::new(tangent, tol)?,
797        });
798        (line.into(), speed)
799    } else {
800        let centre = match surface {
801            SurfaceGeometry::Sphere(s) if fixed_u => s.sphere().centre(),
802            SurfaceGeometry::Torus(t) if fixed_u => {
803                let torus = t.torus();
804                let f = torus.frame();
805                let out = f.x().vector() * fixed.cos() + f.y().vector() * fixed.sin();
806                f.origin() + out * torus.major_radius()
807            }
808            _ => {
809                // A parallel: centred on the axis at the start's height.
810                let f = axis_frame(surface)?;
811                let z = f.z().vector();
812                f.origin() + z * (start - f.origin()).dot(z)
813            }
814        };
815        let radial = start - centre;
816        let radius = radial.magnitude();
817        if radius <= tol.confusion() {
818            ogeom_bail!(
819                Construction,
820                "the parameter line collapses to a point; no edge runs along it"
821            );
822        }
823        let frame = Frame::new(
824            centre,
825            Direction::from_cross(radial, tangent, tol)?,
826            Direction::new(radial, tol)?,
827            tol,
828        )?;
829        let circle = CircleCurve::new(Circle::new(frame, radius, tol)?);
830        (circle.into(), speed / radius)
831    };
832    let end = rate * (hi - lo);
833    let pcurve: PlanarCurve = if (rate - 1.0).abs() <= f64::EPSILON * 16.0 {
834        Line2d::over(
835            Axis2 {
836                location: start_uv,
837                direction: heading,
838            },
839            0.0,
840            end,
841        )?
842        .into()
843    } else {
844        let knots = ogeom_math::KnotVector::new(vec![0.0, 0.0, end, end], 1)?;
845        ogeom_geom::BSpline2d::new(knots, vec![start_uv, place(hi)], tol)?.into()
846    };
847    Ok((curve, (0.0, end), pcurve))
848}
849
850/// The frame whose `z` is a surface of revolution's axis.
851fn axis_frame(surface: &SurfaceGeometry) -> OgeomResult<Frame> {
852    Ok(match surface {
853        SurfaceGeometry::Cylinder(c) => c.cylinder().frame(),
854        SurfaceGeometry::Cone(c) => c.cone().frame(),
855        SurfaceGeometry::Sphere(s) => s.sphere().frame(),
856        SurfaceGeometry::Torus(t) => t.torus().frame(),
857        _ => ogeom_bail!(Construction, "the surface has no axis"),
858    })
859}
860
861/// Give each edge the trims it has on `from` on `to` as well: the surface
862/// grown keeps its parameters, so a trim on the old surface is one on the
863/// new.
864fn retrim(model: &mut Model, edges: &[Shape], from: SurfaceId, to: SurfaceId) -> OgeomResult<()> {
865    let mut done: Vec<Shape> = Vec::new();
866    for edge in edges {
867        if done.iter().any(|d| d.node() == edge.node()) {
868            continue;
869        }
870        done.push(edge.clone());
871        let Some(NodeData::Edge(data)) = model.node_mut(edge).map(|n| n.data_mut()) else {
872            ogeom_bail!(Dangling, "edge is not in this model");
873        };
874        let agreed = data.same_parameter();
875        let copies: Vec<EdgeRepr> = data
876            .representations
877            .iter()
878            .filter_map(|r| match r {
879                EdgeRepr::PCurve {
880                    curve,
881                    surface,
882                    location,
883                    range,
884                } if *surface == from => Some(EdgeRepr::PCurve {
885                    curve: *curve,
886                    surface: to,
887                    location: location.clone(),
888                    range: *range,
889                }),
890                EdgeRepr::Seam {
891                    forward,
892                    reversed,
893                    surface,
894                    location,
895                    range,
896                } if *surface == from => Some(EdgeRepr::Seam {
897                    forward: *forward,
898                    reversed: *reversed,
899                    surface: to,
900                    location: location.clone(),
901                    range: *range,
902                }),
903                _ => None,
904            })
905            .collect();
906        for repr in copies {
907            data.add(repr);
908        }
909        data.assert_same_parameter(agreed);
910    }
911    Ok(())
912}
913
914/// A vertex's point, placed.
915fn vertex_point(model: &Model, vertex: &Shape) -> OgeomResult<Point> {
916    let Some(data) = model.node(vertex).and_then(|n| n.data().as_vertex()) else {
917        ogeom_bail!(Dangling, "vertex is not in this model");
918    };
919    Ok(vertex.transform(model.datums())?.apply(data.point))
920}
921
922/// `count + 1` points along an edge's curve, placed, from its range's start
923/// to its end.
924fn edge_samples(
925    model: &Model,
926    edge: &Shape,
927    count: u32,
928    tol: Tolerances,
929) -> OgeomResult<Vec<Point>> {
930    let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
931        ogeom_bail!(Dangling, "edge is not in this model");
932    };
933    let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
934        ogeom_bail!(Construction, "an edge of the face has no curve in space");
935    };
936    let Some(geometry) = model.geometry().curve(*curve) else {
937        ogeom_bail!(Dangling, "curve is not in this model");
938    };
939    let placement = edge.transform(model.datums())?;
940    (0..=count)
941        .map(|k| {
942            let t = range.0 + (range.1 - range.0) * f64::from(k) / f64::from(count);
943            Ok(placement.apply(geometry.point_at(t, tol)?))
944        })
945        .collect()
946}