Skip to main content

geop_core_sketch/space/
mod.rs

1//! 3-D sketches: points in space, and the lines, arcs and splines through
2//! them — the paths sweeps, pipes, frames and wire routes run along, and the
3//! rails a sweep is guided by.
4//!
5//! The same principles as the planar [`crate::Sketch`]:
6//!
7//! - **Points carry all positions.** Curves refer to points by
8//!   [`PointId`], so curves sharing a point stay connected by construction;
9//!   [`Constraint3d::Coincident`] joins separately drawn points into one.
10//! - **What is given from outside is fixed.** A point at a vertex of the
11//!   part or at a datum point is a *fixed* point, put where that is by
12//!   whoever owns the sketch; an edge of the part a point is constrained
13//!   onto is a [`CurveKind3d::Reference`] curve, its geometry handed in
14//!   ([`Sketch3d::references`]). The solver never moves either.
15//! - **Directions are given.** An axis or a straight edge a line is
16//!   parallel to, or a tangent a spline leaves along, is a direction vector
17//!   in the constraint, brought up to date by the sketch's owner.
18//!
19//! Curves:
20//!
21//! - a **line** between two points;
22//! - a **circular arc** from a point, through a second, to a third — the
23//!   arc lies in their plane;
24//! - a **spline** through points: the natural cubic spline through them
25//!   (see [`geop_core_geometry::nurb_curve::NurbCurve::cubic_spline`]),
26//!   whose ends are free unless a [`Constraint3d::TangentTo`] gives them a
27//!   direction or a [`Constraint3d::Tangent`] joins them smoothly to the
28//!   curve before or after. Either is how the spline is *built*, not
29//!   something the solver moves points for: a spline end tangent to a line
30//!   leaves along the line.
31//!
32//! [`solve`](Sketch3d::solve) moves the points so the constraints hold,
33//! [`enclose`](Sketch3d::enclose) encloses the exact solution, and
34//! [`chains`](Sketch3d::chains) / [`Chain3d::to_nurbs`] turn the curves into
35//! NURBS in space, chain by chain.
36//! [`wire_edges`](Sketch3d::wire_edges) / [`wire_edge_nurbs`](Sketch3d::wire_edge_nurbs)
37//! turn them into the edges of a wire, cut where curves share a point, and
38//! [`check_wire`](Sketch3d::check_wire) refuses curves meeting anywhere
39//! else.
40
41mod curves;
42mod geometry;
43mod solve;
44#[cfg(test)]
45mod tests;
46mod wire;
47
48pub use curves::{Chain3d, Piece3d};
49pub use geometry::{Arc3, Helix3};
50pub use solve::Solve3dReport;
51pub use wire::WireEdge;
52
53use std::collections::BTreeMap;
54
55use geop_core_geometry::nurb_curve::NurbCurve3D;
56use geop_core_math::{
57    geop_error::{GeopError, GeopResult},
58    scalars::{Scalar, as_f64},
59    vector::Vector3,
60};
61use serde::{Deserialize, Serialize};
62
63use crate::{
64    ConstraintId, CurveId, PointId,
65    sketch::{Uses, is_false, point_classes, remove},
66};
67
68/// A point of a 3-D sketch.
69#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
70pub struct Point3d {
71    pub at: Vector3,
72    /// Given rather than solved for: a vertex of the part, a datum point.
73    #[serde(default, skip_serializing_if = "is_false")]
74    pub fixed: bool,
75}
76
77/// What a curve of a 3-D sketch is.
78#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
79#[serde(tag = "type")]
80pub enum CurveKind3d {
81    Line {
82        start: PointId,
83        end: PointId,
84    },
85    /// The circular arc from `start` through `through` to `end`.
86    Arc {
87        start: PointId,
88        through: PointId,
89        end: PointId,
90    },
91    /// The cubic spline through `points`, in order.
92    Spline {
93        points: Vec<PointId>,
94    },
95    /// The helix round the axis from `base` to `top` from `start`, as far
96    /// up the axis as `top` is from `base`, in `turns` turns: right-handed,
97    /// or left-handed if `left`. Its radius is how far `start` is from the
98    /// axis. Where it arrives, `end`, follows from the rest, by a
99    /// [`Constraint3d::HelixEnd`] ([`Sketch3d::add_helix`] adds it).
100    Helix {
101        start: PointId,
102        base: PointId,
103        top: PointId,
104        end: PointId,
105        #[serde(with = "as_f64")]
106        turns: Scalar,
107        #[serde(default, skip_serializing_if = "is_false")]
108        left: bool,
109    },
110    /// A curve given from outside — an edge of the part — whose geometry is
111    /// in [`Sketch3d::references`]: only for points to be constrained onto.
112    Reference,
113}
114
115#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
116pub struct Curve3d {
117    #[serde(flatten)]
118    pub kind: CurveKind3d,
119    /// Takes part in constraints, but in no chain.
120    #[serde(default, skip_serializing_if = "is_false")]
121    pub construction: bool,
122}
123
124impl Uses for Curve3d {
125    fn points(&self) -> Vec<PointId> {
126        Curve3d::points(self)
127    }
128}
129
130impl Curve3d {
131    /// The points it is defined by: its start first, its end last.
132    pub fn points(&self) -> Vec<PointId> {
133        match &self.kind {
134            CurveKind3d::Line { start, end } => vec![*start, *end],
135            CurveKind3d::Arc {
136                start,
137                through,
138                end,
139            } => vec![*start, *through, *end],
140            CurveKind3d::Spline { points } => points.clone(),
141            CurveKind3d::Helix {
142                start,
143                base,
144                top,
145                end,
146                ..
147            } => vec![*start, *base, *top, *end],
148            CurveKind3d::Reference => Vec::new(),
149        }
150    }
151
152    /// `(start, end)`; none for a reference curve, which has no points of
153    /// the sketch.
154    pub fn endpoints(&self) -> Option<(PointId, PointId)> {
155        match &self.kind {
156            CurveKind3d::Line { start, end }
157            | CurveKind3d::Arc { start, end, .. }
158            | CurveKind3d::Helix { start, end, .. } => Some((*start, *end)),
159            CurveKind3d::Spline { points } => Some((*points.first()?, *points.last()?)),
160            CurveKind3d::Reference => None,
161        }
162    }
163
164    /// Whether it is drawn — a line, an arc, a spline or a helix — rather
165    /// than given.
166    pub fn is_drawn(&self) -> bool {
167        !matches!(self.kind, CurveKind3d::Reference)
168    }
169}
170
171/// An end of a curve.
172#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case")]
174pub enum End {
175    Start,
176    End,
177}
178
179/// A coordinate axis.
180#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
181#[serde(rename_all = "snake_case")]
182pub enum Coordinate {
183    X,
184    Y,
185    Z,
186}
187
188impl Coordinate {
189    pub const ALL: [Coordinate; 3] = [Coordinate::X, Coordinate::Y, Coordinate::Z];
190
191    /// Its index in a vector.
192    pub fn index(self) -> usize {
193        match self {
194            Coordinate::X => 0,
195            Coordinate::Y => 1,
196            Coordinate::Z => 2,
197        }
198    }
199
200    pub fn name(self) -> &'static str {
201        match self {
202            Coordinate::X => "x",
203            Coordinate::Y => "y",
204            Coordinate::Z => "z",
205        }
206    }
207}
208
209/// The constraints of a 3-D sketch. Lengths are in sketch units.
210#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
211#[serde(tag = "type")]
212pub enum Constraint3d {
213    /// `a` and `b` are the same point.
214    Coincident { a: PointId, b: PointId },
215    /// The coordinate `axis` of `point` is `value`.
216    Coordinate {
217        point: PointId,
218        axis: Coordinate,
219        #[serde(with = "as_f64")]
220        value: Scalar,
221    },
222    Distance {
223        a: PointId,
224        b: PointId,
225        #[serde(with = "as_f64")]
226        value: Scalar,
227    },
228    /// The length of a line.
229    Length {
230        line: CurveId,
231        #[serde(with = "as_f64")]
232        value: Scalar,
233    },
234    /// The radius of an arc.
235    Radius {
236        arc: CurveId,
237        #[serde(with = "as_f64")]
238        value: Scalar,
239    },
240    /// Two lines are parallel.
241    Parallel { a: CurveId, b: CurveId },
242    /// A line runs along `direction`: an axis, a straight edge.
243    ParallelTo { line: CurveId, direction: Vector3 },
244    /// An arc or a spline leaves (`end` its start) or arrives (its end)
245    /// along `direction`, either way along it.
246    TangentTo {
247        curve: CurveId,
248        end: End,
249        direction: Vector3,
250    },
251    /// `point` lies on `curve`: within a line or an arc's full circle, or
252    /// on a reference curve.
253    OnCurve { point: PointId, curve: CurveId },
254    /// Two curves meeting at an end of each go on smoothly there.
255    Tangent { a: CurveId, b: CurveId },
256    /// The end of a helix is where the rest of it says.
257    HelixEnd { helix: CurveId },
258}
259
260impl Uses for Constraint3d {
261    fn points(&self) -> Vec<PointId> {
262        Constraint3d::points(self)
263    }
264
265    fn curves(&self) -> Vec<CurveId> {
266        Constraint3d::curves(self)
267    }
268
269    fn coincident(&self) -> Option<(PointId, PointId)> {
270        match *self {
271            Constraint3d::Coincident { a, b } => Some((a, b)),
272            _ => None,
273        }
274    }
275}
276
277impl Constraint3d {
278    /// The points it refers to directly, curves aside.
279    pub fn points(&self) -> Vec<PointId> {
280        use Constraint3d::*;
281        match *self {
282            Coincident { a, b } | Distance { a, b, .. } => vec![a, b],
283            Coordinate { point, .. } | OnCurve { point, .. } => vec![point],
284            _ => Vec::new(),
285        }
286    }
287
288    /// The curves it refers to.
289    pub fn curves(&self) -> Vec<CurveId> {
290        use Constraint3d::*;
291        match *self {
292            Length { line: c, .. }
293            | Radius { arc: c, .. }
294            | ParallelTo { line: c, .. }
295            | TangentTo { curve: c, .. }
296            | OnCurve { curve: c, .. }
297            | HelixEnd { helix: c } => vec![c],
298            Parallel { a, b } | Tangent { a, b } => vec![a, b],
299            Coincident { .. } | Coordinate { .. } | Distance { .. } => Vec::new(),
300        }
301    }
302
303    /// The dimension it holds, if it holds one: what a dialog edits.
304    pub fn value(&self) -> Option<Scalar> {
305        use Constraint3d::*;
306        match *self {
307            Coordinate { value, .. }
308            | Distance { value, .. }
309            | Length { value, .. }
310            | Radius { value, .. } => Some(value),
311            _ => None,
312        }
313    }
314
315    /// Gives its dimension the value `v`; nothing for one without.
316    pub fn set_value(&mut self, v: Scalar) {
317        use Constraint3d::*;
318        match self {
319            Coordinate { value, .. }
320            | Distance { value, .. }
321            | Length { value, .. }
322            | Radius { value, .. } => *value = v,
323            _ => {}
324        }
325    }
326}
327
328/// A 3-D sketch's geometry as the kernel builds on it: every point, solved
329/// (see [`Sketch3d::enclose`]) or as drawn ([`Enclosure3d::as_drawn`]).
330#[derive(Clone, Debug, PartialEq)]
331pub struct Enclosure3d {
332    pub points: BTreeMap<PointId, Vector3>,
333}
334
335impl Enclosure3d {
336    /// `sketch`'s points exactly as drawn.
337    pub fn as_drawn(sketch: &Sketch3d) -> Self {
338        Enclosure3d {
339            points: sketch.points.iter().map(|(&id, p)| (id, p.at)).collect(),
340        }
341    }
342}
343
344/// A 3-D constraint sketch (see the module docs). Ids are handed out once
345/// and never reused, as in a planar [`crate::Sketch`].
346#[derive(Clone, Debug, Default, Serialize, Deserialize)]
347pub struct Sketch3d {
348    pub points: BTreeMap<PointId, Point3d>,
349    pub curves: BTreeMap<CurveId, Curve3d>,
350    pub constraints: BTreeMap<ConstraintId, Constraint3d>,
351    /// The id the next added entity gets: one counter for all three kinds.
352    pub next_id: u64,
353    /// The geometry of every [`CurveKind3d::Reference`] curve: given from
354    /// outside, so not saved with the sketch — its owner hands it in before
355    /// solving or building.
356    #[serde(skip)]
357    pub references: BTreeMap<CurveId, NurbCurve3D>,
358}
359
360/// Two sketches are the same design: the same points, curves and
361/// constraints — whatever geometry was handed in for their references.
362impl PartialEq for Sketch3d {
363    fn eq(&self, other: &Self) -> bool {
364        self.points == other.points
365            && self.curves == other.curves
366            && self.constraints == other.constraints
367            && self.next_id == other.next_id
368    }
369}
370
371impl Sketch3d {
372    pub fn new() -> Self {
373        Self::default()
374    }
375
376    pub fn point(&self, id: PointId) -> GeopResult<&Point3d> {
377        self.points
378            .get(&id)
379            .ok_or_else(|| GeopError::new(format!("3-D sketch has no point {id}")))
380    }
381
382    pub fn curve(&self, id: CurveId) -> GeopResult<&Curve3d> {
383        self.curves
384            .get(&id)
385            .ok_or_else(|| GeopError::new(format!("3-D sketch has no curve {id}")))
386    }
387
388    fn fresh_id(&mut self) -> u64 {
389        let id = self.next_id;
390        self.next_id += 1;
391        id
392    }
393
394    pub fn add_point(&mut self, at: Vector3) -> PointId {
395        let id = PointId(self.fresh_id());
396        self.points.insert(id, Point3d { at, fixed: false });
397        id
398    }
399
400    /// A point given from outside the sketch, at `at`.
401    pub fn add_fixed_point(&mut self, at: Vector3) -> PointId {
402        let id = PointId(self.fresh_id());
403        self.points.insert(id, Point3d { at, fixed: true });
404        id
405    }
406
407    pub fn add_curve(&mut self, kind: CurveKind3d) -> CurveId {
408        let id = CurveId(self.fresh_id());
409        self.curves.insert(
410            id,
411            Curve3d {
412                kind,
413                construction: false,
414            },
415        );
416        id
417    }
418
419    pub fn add_line(&mut self, start: PointId, end: PointId) -> CurveId {
420        self.add_curve(CurveKind3d::Line { start, end })
421    }
422
423    pub fn add_arc(&mut self, start: PointId, through: PointId, end: PointId) -> CurveId {
424        self.add_curve(CurveKind3d::Arc {
425            start,
426            through,
427            end,
428        })
429    }
430
431    /// A helix (see [`CurveKind3d::Helix`]) with its end where the rest of
432    /// it says, a new point.
433    pub fn add_helix(
434        &mut self,
435        start: PointId,
436        base: PointId,
437        top: PointId,
438        turns: Scalar,
439        left: bool,
440    ) -> GeopResult<CurveId> {
441        let at = |p: PointId| self.point(p).map(|p| p.at);
442        let end = (Helix3 {
443            start: at(start)?,
444            base: at(base)?,
445            top: at(top)?,
446            turns,
447            left,
448        })
449        .end()?;
450        let end = self.add_point(end);
451        let helix = self.add_curve(CurveKind3d::Helix {
452            start,
453            base,
454            top,
455            end,
456            turns,
457            left,
458        });
459        self.constrain(Constraint3d::HelixEnd { helix });
460        Ok(helix)
461    }
462
463    pub fn add_spline(&mut self, points: Vec<PointId>) -> CurveId {
464        self.add_curve(CurveKind3d::Spline { points })
465    }
466
467    /// A curve given from outside, `curve`, to constrain points onto.
468    pub fn add_reference(&mut self, curve: NurbCurve3D) -> CurveId {
469        let id = self.add_curve(CurveKind3d::Reference);
470        self.references.insert(id, curve);
471        id
472    }
473
474    pub fn constrain(&mut self, constraint: Constraint3d) -> ConstraintId {
475        let id = ConstraintId(self.fresh_id());
476        self.constraints.insert(id, constraint);
477        id
478    }
479
480    /// Removes `points`, `curves` and `constraints`, and everything that
481    /// depends on them (see [`crate::sketch::remove`]) — and the geometry
482    /// of the reference curves removed.
483    pub fn remove(&mut self, points: &[PointId], curves: &[CurveId], constraints: &[ConstraintId]) {
484        let (all_points, all_curves) = (&mut self.points, &mut self.curves);
485        let dead_curves = remove(
486            all_points,
487            all_curves,
488            &mut self.constraints,
489            points,
490            curves,
491            constraints,
492        );
493        self.references.retain(|id, _| !dead_curves.contains(id));
494    }
495
496    /// Union-find representative of every point under
497    /// [`Constraint3d::Coincident`] — the lowest id of its class: points
498    /// with the same representative are one point.
499    pub fn point_classes(&self) -> BTreeMap<PointId, PointId> {
500        point_classes(&self.points, &self.constraints)
501    }
502
503    /// Which ends of `a` and `b` are one point, if any.
504    pub fn shared_end(&self, a: CurveId, b: CurveId) -> GeopResult<Option<(End, End)>> {
505        let (Some((a0, a1)), Some((b0, b1))) =
506            (self.curve(a)?.endpoints(), self.curve(b)?.endpoints())
507        else {
508            return Ok(None);
509        };
510        let class = self.point_classes();
511        let at = |p: PointId| class[&p];
512        let pick = |e: End, s: PointId, t: PointId| if e == End::Start { s } else { t };
513        Ok([
514            (End::Start, End::Start),
515            (End::Start, End::End),
516            (End::End, End::Start),
517            (End::End, End::End),
518        ]
519        .into_iter()
520        .find(|&(ea, eb)| at(pick(ea, a0, a1)) == at(pick(eb, b0, b1))))
521    }
522
523    /// Checks every reference and every constraint's operands, so the
524    /// solver and the curves can rely on them.
525    pub fn validate(&self) -> GeopResult<()> {
526        let max_id = [
527            self.points.keys().last().map(|id| id.0),
528            self.curves.keys().last().map(|id| id.0),
529            self.constraints.keys().last().map(|id| id.0),
530        ];
531        if let Some(max_id) = max_id.into_iter().flatten().max()
532            && max_id >= self.next_id
533        {
534            return Err(GeopError::new(format!(
535                "3-D sketch uses id {max_id}, but its next_id is only {}",
536                self.next_id
537            )));
538        }
539        let class = self.point_classes();
540        for (&i, curve) in &self.curves {
541            for p in curve.points() {
542                self.point(p)
543                    .map_err(|e| e.with_context(format!("curve {i}")))?;
544            }
545            let distinct = |ps: &[PointId]| ps.windows(2).all(|w| class[&w[0]] != class[&w[1]]);
546            match &curve.kind {
547                CurveKind3d::Line { start, end } if class[start] == class[end] => {
548                    return Err(GeopError::new(format!(
549                        "line {i} starts and ends at the same point"
550                    )));
551                }
552                CurveKind3d::Arc {
553                    start,
554                    through,
555                    end,
556                } if !distinct(&[*start, *through, *end]) || class[start] == class[end] => {
557                    return Err(GeopError::new(format!(
558                        "arc {i} needs three different points"
559                    )));
560                }
561                CurveKind3d::Spline { points } if points.len() < 2 || !distinct(points) => {
562                    return Err(GeopError::new(format!(
563                        "spline {i} needs at least two points, each different from the one before"
564                    )));
565                }
566                CurveKind3d::Helix {
567                    start,
568                    base,
569                    top,
570                    turns,
571                    ..
572                } if class[base] == class[top]
573                    || class[start] == class[base]
574                    || !turns.definitely_greater(Scalar::ZERO) =>
575                {
576                    return Err(GeopError::new(format!(
577                        "helix {i} needs an axis, a start off it and more than no turns"
578                    )));
579                }
580                CurveKind3d::Reference if !self.references.contains_key(&i) => {
581                    return Err(GeopError::new(format!(
582                        "reference curve {i} has no geometry handed in"
583                    )));
584                }
585                _ => {}
586            }
587        }
588        // Each spline end gets its direction from one place at most.
589        let mut directed: BTreeMap<(CurveId, End), ConstraintId> = BTreeMap::new();
590        for (&k, c) in &self.constraints {
591            self.validate_constraint(c)
592                .map_err(|e| e.with_context(format!("constraint {k} = {c:?}")))?;
593            for (curve, end) in self.spline_ends_directed(c)? {
594                if let Some(other) = directed.insert((curve, end), k) {
595                    return Err(GeopError::new(format!(
596                        "the {end:?} of spline {curve} is given its direction twice, by constraints {other} and {k}: a spline end leaves along one direction"
597                    )));
598                }
599            }
600        }
601        Ok(())
602    }
603
604    fn validate_constraint(&self, c: &Constraint3d) -> GeopResult<()> {
605        use Constraint3d::*;
606        for p in c.points() {
607            self.point(p)?;
608        }
609        let kind = |id: CurveId| self.curve(id).map(|c| &c.kind);
610        let is_line = |id| Ok::<_, GeopError>(matches!(kind(id)?, CurveKind3d::Line { .. }));
611        let need = |ok: bool, what: &str| {
612            if ok {
613                Ok(())
614            } else {
615                Err(GeopError::new(format!("operands must be {what}")))
616            }
617        };
618        match c {
619            Coincident { .. } | Coordinate { .. } | Distance { .. } => Ok(()),
620            Length { line, .. } => need(is_line(*line)?, "a line"),
621            Radius { arc, .. } => need(matches!(kind(*arc)?, CurveKind3d::Arc { .. }), "an arc"),
622            Parallel { a, b } => need(is_line(*a)? && is_line(*b)?, "two lines"),
623            ParallelTo { line, direction } => {
624                need(is_line(*line)?, "a line")?;
625                direction.normalize().map(|_| ())
626            }
627            TangentTo {
628                curve, direction, ..
629            } => {
630                need(
631                    matches!(
632                        kind(*curve)?,
633                        CurveKind3d::Arc { .. } | CurveKind3d::Spline { .. }
634                    ),
635                    "an arc or a spline",
636                )?;
637                direction.normalize().map(|_| ())
638            }
639            OnCurve { curve, .. } => need(
640                !matches!(
641                    kind(*curve)?,
642                    CurveKind3d::Spline { .. } | CurveKind3d::Helix { .. }
643                ),
644                "a point and a line, an arc or an edge (a point on a spline or a helix is not supported)",
645            ),
646            Tangent { a, b } => {
647                need(
648                    self.curve(*a)?.is_drawn() && self.curve(*b)?.is_drawn(),
649                    "two drawn curves",
650                )?;
651                let helix = |c: CurveId| matches!(kind(c), Ok(CurveKind3d::Helix { .. }));
652                need(!helix(*a) && !helix(*b), "curves other than a helix")?;
653                need(
654                    self.shared_end(*a, *b)?.is_some(),
655                    "two curves meeting at an end of each",
656                )
657            }
658            HelixEnd { helix } => need(
659                matches!(kind(*helix)?, CurveKind3d::Helix { .. }),
660                "a helix",
661            ),
662        }
663    }
664
665    /// The spline ends `c` gives a direction to: a [`Constraint3d::TangentTo`]
666    /// its curve's end, a [`Constraint3d::Tangent`] the end of the spline that
667    /// follows the other curve's direction (see [`Sketch3d::adopts`]).
668    fn spline_ends_directed(&self, c: &Constraint3d) -> GeopResult<Vec<(CurveId, End)>> {
669        Ok(match *c {
670            Constraint3d::TangentTo { curve, end, .. }
671                if matches!(self.curve(curve)?.kind, CurveKind3d::Spline { .. }) =>
672            {
673                vec![(curve, end)]
674            }
675            Constraint3d::Tangent { a, b } => self.adopts(a, b)?.into_iter().collect(),
676            _ => Vec::new(),
677        })
678    }
679
680    /// For a [`Constraint3d::Tangent`] between `a` and `b`, which spline end
681    /// takes the other curve's direction there, if one does: a spline's end
682    /// meeting a line or an arc; of two splines, the newer one's (the
683    /// higher id), so that no spline ever waits on itself. `None` where
684    /// neither is a spline: the solver makes those two tangent.
685    pub fn adopts(&self, a: CurveId, b: CurveId) -> GeopResult<Option<(CurveId, End)>> {
686        let Some((ea, eb)) = self.shared_end(a, b)? else {
687            return Ok(None);
688        };
689        let spline = |c: CurveId| -> GeopResult<bool> {
690            Ok(matches!(self.curve(c)?.kind, CurveKind3d::Spline { .. }))
691        };
692        Ok(match (spline(a)?, spline(b)?) {
693            (false, false) => None,
694            (true, false) => Some((a, ea)),
695            (false, true) => Some((b, eb)),
696            (true, true) if a > b => Some((a, ea)),
697            (true, true) => Some((b, eb)),
698        })
699    }
700}