Skip to main content

geop_ops_harness/
route.rs

1//! The path a cable takes: through [`Waypoint`]s, in order, as a chain of
2//! straight lines and circular arcs ([`RoutePath`]), and what it measures
3//! ([`Measure`]): how long it is and how tightly it bends.
4//!
5//! # Why lines and arcs
6//!
7//! A cable is laid the way a pipe is bent: straight runs and bends of one
8//! radius each. Between two waypoints, each with the direction the route
9//! has there, the route is a *biarc* — two circular arcs, tangent to each
10//! other where they meet and to the given directions at the ends, either
11//! of them straight where the geometry is. That makes the whole route
12//! tangent-continuous, and everything about it exact:
13//!
14//! - its **length** is the sum of chords and of radius times angle, an
15//!   enclosure of the true length, not an approximation of it;
16//! - its **bend radii** are the arcs' radii, so the minimum bend radius is
17//!   checked exactly, arc by arc, and a violation names the two waypoints
18//!   the arc lies between;
19//! - the **bundle** swept along it is made of cylinders and pieces of tori,
20//!   which the path sweep builds exactly (see
21//!   [`geop_lib_sweep::sweep_along`]).
22//!
23//! # The biarc
24//!
25//! From `P0` heading `T0` to `P1` heading `T1` (unit vectors), the two arcs
26//! meet at `J = (Q1 + Q2) / 2` with `Q1 = P0 + d T0` and `Q2 = P1 - d T1`,
27//! the arcs' middle control points: the arc from `P0` to `J` has both legs
28//! `d` long, as has the one from `J` to `P1`, which is what makes each a
29//! circular arc. `|Q2 - Q1| = 2d` gives `d` as the positive root of
30//!
31//! ```text
32//! 2 (T0·T1 - 1) d² - 2 v·(T0 + T1) d + v·v = 0,    v = P1 - P0,
33//! ```
34//!
35//! which always has exactly one, since the leading coefficient is never
36//! positive and the constant always is. Equal legs on both arcs is the
37//! usual choice among the biarcs between two ends (it is a one-parameter
38//! family); it is symmetric, and a straight route or a single arc comes out
39//! as exactly that. Everything is computed in the scalars' own arithmetic,
40//! so the arcs are honest enclosures, recognized as arcs by everything
41//! downstream.
42//!
43//! # Directions
44//!
45//! A point — a vertex, a sketch's point, a datum point — leaves the
46//! direction free, and the route chooses it (see [`RoutePath::through`]).
47//! A circular edge — a clip, a grommet — says the axis the route passes
48//! along, and the route passes the way it is going. Anything else gives a
49//! coordinate system — a datum frame, or the frame on a face or an edge
50//! (see [`MateEntity::frame`]) — which says which way the cable leaves it:
51//! along its `z` axis — out of a connector at the start, into one (against
52//! its `z` axis) at the end, along it in between.
53
54use geop_core_geometry::nurb_curve::NurbCurve3D;
55use geop_core_math::{
56    geop_error::{GeopError, GeopResult, WithContext},
57    primitives::DatumKind,
58    scalars::Scalar,
59    vector::Vector3,
60    with_context,
61};
62use geop_core_program::reader::Reader;
63use geop_ops_model::Chain;
64use geop_ops_model::mate_entity::MateEntity;
65use geop_ops_model::parameters::located;
66use geop_ops_model::selection::{Selected, Shape};
67
68/// Which way the route is told to run through a [`Waypoint`].
69#[derive(Clone, Debug)]
70pub enum Heading {
71    /// Not at all: the route chooses.
72    Free,
73    /// Along this axis, either way: the route passes the way it is going.
74    Axis(Vector3),
75    /// This way: out of a connector, or along a coordinate system's `z`.
76    Exit(Vector3),
77}
78
79/// A point the route runs through, and which way.
80#[derive(Clone, Debug)]
81pub struct Waypoint {
82    pub point: Vector3,
83    pub heading: Heading,
84    /// What the route calls it when it says something about it:
85    /// `point 2 (clip/extrude(c,end))`.
86    pub label: String,
87}
88
89impl Heading {
90    /// It with its direction turned by `turn`.
91    fn turned(self, turn: impl Fn(&Vector3) -> Vector3) -> Self {
92        match self {
93            Heading::Free => Heading::Free,
94            Heading::Axis(d) => Heading::Axis(turn(&d)),
95            Heading::Exit(d) => Heading::Exit(turn(&d)),
96        }
97    }
98}
99
100impl Waypoint {
101    /// The `index`-th point the route runs through, `entity` in the part
102    /// `reader` reads — where the part it is of is placed there (see the
103    /// module docs): a point, just where it is; a circular edge, its
104    /// centre and axis; a coordinate system, its origin and `z` axis.
105    pub fn of(reader: &Reader, entity: &Selected, index: usize) -> GeopResult<Self> {
106        let frame_on = MateEntity::on(entity.clone());
107        let label = format!("point {} ({})", index + 1, frame_on.label());
108        let ctx = with_context!("resolving the route's {label}");
109        let unsupported = |what: &str| {
110            GeopError::new(format!(
111                "route: {label} is {what}: a route runs through points, coordinate systems (along their z axis) and circular edges (through their centre, along their axis)"
112            ))
113        };
114        let (inside, pose) = match entity.instance() {
115            "" => (reader.clone(), None),
116            part => {
117                let inside = reader.placed_part(part).with_context(ctx)?;
118                (inside, Some(located(reader, part).with_context(ctx)?))
119            }
120        };
121        let local = entity.local();
122        let (point, heading) = if let Selected::Datum(datum) = &local {
123            let datum = inside.retrieve_element(datum).with_context(ctx)?;
124            let heading = match datum.kind {
125                DatumKind::Point => Heading::Free,
126                DatumKind::Frame => Heading::Exit(*datum.frame.w()),
127                DatumKind::Axis => return Err(unsupported("an axis")),
128                DatumKind::Plane => return Err(unsupported("a plane")),
129            };
130            (*datum.frame.origin(), heading)
131        } else {
132            let shape = local.shape(&inside).with_context(ctx)?;
133            match (&local, shape) {
134                (
135                    Selected::Vertex(_) | Selected::SketchPoint(_),
136                    Shape {
137                        point: Some(point), ..
138                    },
139                ) => (point, Heading::Free),
140                (_, Shape { arc: Some(arc), .. }) => {
141                    (arc.circle.center, Heading::Axis(arc.circle.normal))
142                }
143                // In its own part, which `pose` places.
144                _ => {
145                    let frame = frame_on.frame(reader);
146                    let frame = frame.with_context(ctx)?;
147                    (*frame.origin(), Heading::Exit(*frame.w()))
148                }
149            }
150        };
151        let (point, heading) = match pose {
152            Some(pose) => {
153                let motion = pose.motion();
154                let heading = heading.turned(|d| motion.rotate(d));
155                (motion.apply(&point), heading)
156            }
157            None => (point, heading),
158        };
159        Ok(Waypoint {
160            point,
161            heading,
162            label,
163        })
164    }
165}
166
167/// A route: a chain of lines and arcs through waypoints, named for the
168/// solid swept along it — `w{i}` the `i`-th waypoint, `m{i}` where the two
169/// arcs between it and the next meet, `a{i}` the arc from `w{i}` to `m{i}`
170/// (or the line from `w{i}` to `w{i+1}`, if both are straight) and `b{i}`
171/// the arc from `m{i}` on.
172#[derive(Clone, Debug)]
173pub struct RoutePath {
174    pub chain: Chain<NurbCurve3D>,
175    /// Where each curve of the chain lies, as the route says it:
176    /// `between point 1 (…) and point 2 (…)`.
177    pub places: Vec<String>,
178}
179
180/// What a route measures.
181#[derive(Clone, Debug)]
182pub struct Measure {
183    /// End to end.
184    pub length: Scalar,
185    /// Every bend, in order.
186    pub bends: Vec<Bend>,
187}
188
189/// A bend of a route: the arc `curve` of its chain, of radius `radius`.
190#[derive(Clone, Debug)]
191pub struct Bend {
192    pub curve: usize,
193    pub radius: Scalar,
194}
195
196impl Measure {
197    /// The tightest bend, if the route bends at all.
198    pub fn tightest(&self) -> Option<&Bend> {
199        self.bends
200            .iter()
201            .min_by(|a, b| a.radius.to_f64().total_cmp(&b.radius.to_f64()))
202    }
203
204    /// The bends definitely tighter than `min_radius`. One that could be
205    /// exactly as tight — a bend designed at the limit — is not.
206    pub fn too_tight(&self, min_radius: f64) -> Vec<&Bend> {
207        let min = Scalar::from_f64(min_radius);
208        self.bends
209            .iter()
210            .filter(|b| b.radius.definitely_less(min))
211            .collect()
212    }
213}
214
215impl RoutePath {
216    /// The route through `waypoints`, in order (see the module docs). Where
217    /// a waypoint leaves the direction free, the route chooses it: in
218    /// between, it turns as much before as after, along the bisector of the
219    /// chords to its neighbours; at an end, it bends into the next
220    /// waypoint's direction in one arc — or runs straight, between two free
221    /// ends.
222    pub fn through(waypoints: &[Waypoint]) -> GeopResult<Self> {
223        let n = waypoints.len();
224        if n < 2 {
225            return Err(GeopError::new(format!(
226                "route: pick the points the route runs through, two at least — {n} picked"
227            )));
228        }
229        let tangents = tangents(waypoints)?;
230        let label = |i: usize| &waypoints[i].label;
231        let mut chain = Chain {
232            name: "route".to_string(),
233            curves: Vec::new(),
234            curve_names: Vec::new(),
235            joint_names: Vec::new(),
236        };
237        let mut places = Vec::new();
238        for i in 0..n - 1 {
239            let ctx = with_context!("routing from {} to {}", label(i), label(i + 1));
240            let (p0, p1) = (&waypoints[i].point, &waypoints[i + 1].point);
241            let place = format!("between {} and {}", label(i), label(i + 1));
242            let [first, second] =
243                biarc(&place, p0, &tangents[i], p1, &tangents[i + 1]).with_context(ctx)?;
244            let mut push = |curve: NurbCurve3D, name: String, joint: String| {
245                chain.curves.push(curve);
246                chain.curve_names.push(name);
247                chain.joint_names.push(joint);
248                places.push(place.clone());
249            };
250            if first.straight && second.straight {
251                push(
252                    NurbCurve3D::line(*p0, *p1)?,
253                    format!("a{i}"),
254                    format!("w{i}"),
255                );
256            } else {
257                push(first.curve, format!("a{i}"), format!("w{i}"));
258                push(second.curve, format!("b{i}"), format!("m{i}"));
259            }
260        }
261        chain.joint_names.push(format!("w{}", n - 1));
262        Ok(RoutePath { chain, places })
263    }
264
265    /// How long the route is and where it bends how tightly. Its curves are
266    /// lines and arcs — what [`RoutePath::through`] builds; a chain with
267    /// any other curve is not measured.
268    pub fn measure(&self) -> GeopResult<Measure> {
269        let mut length = Scalar::ZERO;
270        let mut bends = Vec::new();
271        for (k, curve) in self.chain.curves.iter().enumerate() {
272            let ctx = with_context!("measuring the route {}", self.places[k]);
273            // An arc first: a flat one's control points could all lie on
274            // its chord, but its length is not the chord's.
275            if let Some(arc) = curve.as_arc().with_context(ctx)? {
276                length = length.add(arc.length().with_context(ctx)?);
277                bends.push(Bend {
278                    curve: k,
279                    radius: arc.circle.radius,
280                });
281            } else if curve.as_line().with_context(ctx)?.is_some() {
282                let (t0, t1) = curve.domain();
283                let chord = curve.evaluate(t1)?.sub(&curve.evaluate(t0)?);
284                length = length.add(chord.norm());
285            } else {
286                return Err(GeopError::new(format!(
287                    "route: the route {} is neither straight nor a circular arc, and only those are measured",
288                    self.places[k]
289                )));
290            }
291        }
292        Ok(Measure { length, bends })
293    }
294}
295
296/// The unit direction the route has at each waypoint (see
297/// [`RoutePath::through`]).
298fn tangents(waypoints: &[Waypoint]) -> GeopResult<Vec<Vector3>> {
299    let n = waypoints.len();
300    let label = |i: usize| &waypoints[i].label;
301    let chords = (0..n - 1)
302        .map(|i| {
303            let d = waypoints[i + 1].point.sub(&waypoints[i].point);
304            if d.norm_sq().could_be_equal(Scalar::ZERO) {
305                return Err(GeopError::new(format!(
306                    "route: {} and {} could be one point: the route runs between points apart",
307                    label(i),
308                    label(i + 1)
309                )));
310            }
311            d.normalize()
312        })
313        .collect::<GeopResult<Vec<_>>>()?;
314    // Which way the route is going at `i`, as its neighbours say: along the
315    // chords to either side.
316    let going = |i: usize| match (i.checked_sub(1).map(|j| chords[j]), chords.get(i)) {
317        (Some(a), Some(b)) => a.add(b),
318        (Some(a), None) => a,
319        (None, Some(b)) => *b,
320        (None, None) => unreachable!("a route has two waypoints at least"),
321    };
322    let mut tangents: Vec<Option<Vector3>> = Vec::with_capacity(n);
323    for (i, waypoint) in waypoints.iter().enumerate() {
324        tangents.push(match &waypoint.heading {
325            Heading::Exit(d) if i + 1 == n => Some(d.neg().normalize()?),
326            Heading::Exit(d) => Some(d.normalize()?),
327            Heading::Axis(axis) => {
328                let along = axis.prod_dot(&going(i));
329                if along.definitely_greater(Scalar::ZERO) {
330                    Some(axis.normalize()?)
331                } else if along.definitely_less(Scalar::ZERO) {
332                    Some(axis.neg().normalize()?)
333                } else {
334                    return Err(GeopError::new(format!(
335                        "route: the axis of {} could lie square to the route, which then has no way to pass through it: move the points before or after it",
336                        label(i)
337                    )));
338                }
339            }
340            Heading::Free if i == 0 || i + 1 == n => None,
341            Heading::Free => {
342                let bisector = going(i);
343                if bisector.norm_sq().could_be_equal(Scalar::ZERO) {
344                    return Err(GeopError::new(format!(
345                        "route: the route could turn right back at {}",
346                        label(i)
347                    )));
348                }
349                // A free choice, so sharp — before it is normalized: the
350                // biarc takes its directions to be unit vectors, which a
351                // unit vector sharpened afterwards no longer quite is.
352                Some(bisector.sharpen().normalize()?)
353            }
354        });
355    }
356    // A free end bends into its neighbour's direction in one arc: it
357    // leaves along that direction mirrored in the chord between them —
358    // again a free choice.
359    let mirrored = |t: &Vector3, c: &Vector3| {
360        c.prod_scalar(Scalar::TWO.mul(c.prod_dot(t)))
361            .sub(t)
362            .sharpen()
363            .normalize()
364    };
365    if n == 2 && tangents.iter().all(Option::is_none) {
366        return Ok(vec![chords[0]; 2]);
367    }
368    if tangents[0].is_none() {
369        let next = tangents[1].expect("set: a free end with a free neighbour is a route of two");
370        tangents[0] = Some(mirrored(&next, &chords[0])?);
371    }
372    if tangents[n - 1].is_none() {
373        let before = tangents[n - 2].expect("set before");
374        tangents[n - 1] = Some(mirrored(&before, &chords[n - 2])?);
375    }
376    Ok(tangents.into_iter().map(|t| t.expect("all set")).collect())
377}
378
379/// One of a biarc's two curves, and whether it is straight.
380struct Piece {
381    curve: NurbCurve3D,
382    straight: bool,
383}
384
385/// The biarc from `p0` heading `t0` to `p1` heading `t1` (see the module
386/// docs), `place` being where it lies, as the route says it.
387fn biarc(
388    place: &str,
389    p0: &Vector3,
390    t0: &Vector3,
391    p1: &Vector3,
392    t1: &Vector3,
393) -> GeopResult<[Piece; 2]> {
394    let v = p1.sub(p0);
395    let a = Scalar::TWO.mul(t0.prod_dot(t1).sub(Scalar::ONE));
396    let b = Scalar::TWO.mul(v.prod_dot(&t0.add(t1))).neg();
397    let c = v.norm_sq();
398    let root = b
399        .mul(b)
400        .sub(Scalar::TWO.mul(Scalar::TWO).mul(a).mul(c))
401        .sqrt()?;
402    // The positive root `2c / (sqrt(disc) - b)`, which needs no division by
403    // `a`, zero when the two directions are parallel.
404    let denominator = root.sub(b);
405    let numbers = || format!("heading {t0:?} at {p0:?} and {t1:?} at {p1:?}");
406    if !denominator.definitely_greater(Scalar::ZERO) {
407        return Err(GeopError::new(format!(
408            "route: {place}, the route would have to loop round to arrive: add a waypoint between them"
409        ))
410        .with_context(numbers()));
411    }
412    let d = Scalar::TWO.mul(c).div(denominator)?;
413    let q1 = p0.add(&t0.prod_scalar(d));
414    let q2 = p1.sub(&t1.prod_scalar(d));
415    let joint = q1.add(&q2).prod_scalar(Scalar::ONE.div(Scalar::TWO)?);
416    let turns_back = || {
417        GeopError::new(format!(
418            "route: {place}, the route would have to turn right back on itself: add a waypoint between them"
419        ))
420        .with_context(numbers())
421    };
422    match [piece(p0, &q1, &joint)?, piece(&joint, &q2, p1)?] {
423        [Some(first), Some(second)] => Ok([first, second]),
424        _ => Err(turns_back()),
425    }
426}
427
428/// The arc from `a` to `b` whose legs meet at `q` — legs of one length — or
429/// the line, if they could be straight on. None if they could turn right
430/// back.
431fn piece(a: &Vector3, q: &Vector3, b: &Vector3) -> GeopResult<Option<Piece>> {
432    let (first, second) = (q.sub(a), b.sub(q));
433    let dot = first.prod_dot(&second);
434    if first
435        .prod_cross(&second)
436        .norm_sq()
437        .could_be_equal(Scalar::ZERO)
438    {
439        if !dot.definitely_greater(Scalar::ZERO) {
440            return Ok(None);
441        }
442        return Ok(Some(Piece {
443            curve: NurbCurve3D::line(*a, *b)?,
444            straight: true,
445        }));
446    }
447    // The weight is the cosine of half the turn between the legs.
448    let cos = dot.div(first.norm().mul(second.norm()))?;
449    let weight = Scalar::ONE.add(cos).div(Scalar::TWO)?.sqrt()?;
450    Ok(Some(Piece {
451        curve: NurbCurve3D::arc(*a, *q, *b, weight)?,
452        straight: false,
453    }))
454}