Skip to main content

axiolid_mesh_compile/
directrix.rs

1//! Safe resolution of graph-referenced 3D sweep directrices.
2
3use axiolid_construct::sweep::SampledPath;
4use axiolid_contracts::{ExecutionOptions, GeomError, GeomResult};
5use axiolid_core::{Point2, Point3, Scalar, Vec3};
6use axiolid_curve::{Curve2, Curve3, PathBound};
7use axiolid_model::{
8    CurveRelation, GeometryGraph, GeometryNode, MasterRepresentation, NodeId, TrimSelector,
9    TrimmingPreference,
10};
11use axiolid_reference::station::StationCurve;
12
13mod elevated;
14mod pieces;
15pub(crate) use pieces::{exact_pieces, fixed_reference_pieces, pieces, PiecePath};
16
17const MAX_DEPTH: usize = 256;
18const MAX_POINTS: usize = 1_000_000;
19const MAX_FLATTEN_DEPTH: u32 = 16;
20
21pub(crate) fn points(
22    graph: &GeometryGraph,
23    id: NodeId,
24    range: Option<(Scalar, Scalar)>,
25    options: &ExecutionOptions,
26) -> GeomResult<Vec<Point3>> {
27    sampled(graph, id, range, options).map(|path| path.points)
28}
29
30/// The directrix sampled to the options' chord budget, with its exact end
31/// tangents when it is one smooth curve (#231, #232).
32///
33/// A circle or an ellipse, alone or trimmed, reports its tangents: those
34/// are smooth everywhere, so a sweep may refine them until its walls fit
35/// its budget. So does a B-spline with no corner knot (one of full
36/// multiplicity) inside the swept span (#232). A line or polyline is its
37/// own chords, and a composite or a cornered B-spline may carry corners
38/// that no refinement removes, so those are swept as sampled.
39pub(crate) fn sampled(
40    graph: &GeometryGraph,
41    id: NodeId,
42    range: Option<(Scalar, Scalar)>,
43    options: &ExecutionOptions,
44) -> GeomResult<SampledPath> {
45    let (points, end_tangents) = resolve(graph, id, range, options, 0)?;
46    if points.len() < 2 {
47        return Err(GeomError::Degenerate(
48            "a sweep directrix needs at least two points".into(),
49        ));
50    }
51    Ok(SampledPath {
52        points,
53        end_tangents,
54    })
55}
56
57/// Samples in the direction of travel, and the exact end tangents when
58/// known (see [`sampled`]).
59type Resolved = (Vec<Point3>, Option<[Vec3; 2]>);
60
61/// Reverse a resolved path: the samples, and the tangents swapped and
62/// negated so they still point along the travel.
63fn reversed((mut points, ends): Resolved) -> Resolved {
64    points.reverse();
65    (points, ends.map(|[start, end]| [-end, -start]))
66}
67
68/// Unit tangents of a smooth curve at the ends of `[start, end]`, in the
69/// direction of increasing parameter: a conic, or a B-spline with no
70/// corner knot strictly inside (#232); `None` for any other family.
71fn smooth_ends(curve: &axiolid_curve::Curve3, start: Scalar, end: Scalar) -> Option<[Vec3; 2]> {
72    let smooth = match curve {
73        axiolid_curve::Curve3::Circle(_) | axiolid_curve::Curve3::Ellipse(_) => true,
74        axiolid_curve::Curve3::BSpline(_) => {
75            let (lo, hi) = (start.min(end), start.max(end));
76            !axiolid_reference::bound::continuity_breaks3(curve, 1)
77                .into_iter()
78                .any(|knot| knot > lo && knot < hi)
79        }
80        // Smooth unless the grade breaks inside the span (#252).
81        curve if elevated::is_elevated(curve) => return elevated::smooth_ends(curve, start, end),
82        _ => false,
83    };
84    if !smooth {
85        return None;
86    }
87    let unit = |t| {
88        axiolid_reference::curve::derivative3(curve, t)
89            .ok()
90            .map(Vec3::normalize_or_zero)
91            .filter(|v| *v != Vec3::ZERO && v.is_finite())
92    };
93    Some([unit(start)?, unit(end)?])
94}
95
96fn resolve(
97    graph: &GeometryGraph,
98    id: NodeId,
99    range: Option<(Scalar, Scalar)>,
100    options: &ExecutionOptions,
101    depth: usize,
102) -> GeomResult<Resolved> {
103    if depth > MAX_DEPTH {
104        return Err(GeomError::BudgetExceeded {
105            resource: "directrix relation depth",
106        });
107    }
108    match graph.get(id) {
109        Some(GeometryNode::Curve3(curve)) => sample_curve(curve, range, options),
110        Some(GeometryNode::CurveRelation(CurveRelation::SurfaceCurve {
111            curve_3d,
112            master: MasterRepresentation::Curve3d,
113            ..
114        })) => resolve(graph, *curve_3d, range, options, depth + 1),
115        // Selecting curve_3d for a pcurve master can move a seam to the wrong
116        // side of a periodic surface. Until pcurve evaluation and Both-agreement
117        // validation exist, refusing these representations is the safe result.
118        Some(GeometryNode::CurveRelation(CurveRelation::SurfaceCurve { .. })) => {
119            Err(unsupported_curve_evaluation())
120        }
121        Some(GeometryNode::CurveRelation(CurveRelation::Trimmed {
122            basis,
123            start,
124            end,
125            sense_agreement,
126            preference,
127        })) => {
128            // A point selector can only be inverted against a curve, so the
129            // basis is resolved first. A relation basis (trimmed-of-trimmed)
130            // has no single analytic curve to invert against; a parameter
131            // selector still works there, a point selector is refused by name.
132            let basis_curve = match graph.get(*basis) {
133                Some(GeometryNode::Curve3(curve)) => Some(curve),
134                _ => None,
135            };
136            let (a, b) = match basis_curve {
137                Some(curve) => (
138                    parameter(
139                        start,
140                        *preference,
141                        "start",
142                        curve,
143                        *sense_agreement,
144                        options.tolerance(),
145                    )?,
146                    parameter(
147                        end,
148                        *preference,
149                        "end",
150                        curve,
151                        *sense_agreement,
152                        options.tolerance(),
153                    )?,
154                ),
155                None => (
156                    parameter_only(start, *preference, "start")?,
157                    parameter_only(end, *preference, "end")?,
158                ),
159            };
160            if a == b {
161                return Err(GeomError::Degenerate(
162                    "trimmed directrix has an empty interval".into(),
163                ));
164            }
165            // A periodic basis may be trimmed ACROSS its seam: the curve runs
166            // from `a` the way `sense_agreement` says, wrapping if it must.
167            // Sorting `a` and `b` would pick the complementary arc (#168).
168            if let Some((curve, period)) = basis_curve.and_then(|c| period_of(c).map(|p| (c, p))) {
169                let out =
170                    sample_periodic_trim(curve, period, a, b, *sense_agreement, range, options)?;
171                return Ok(if *sense_agreement { out } else { reversed(out) });
172            }
173            let selected = range.unwrap_or((a, b));
174            let lo = a.min(b);
175            let hi = a.max(b);
176            let slack = slack_in_parameter(
177                Some(options.tolerance().linear()),
178                relation_speed(graph, *basis, 0),
179                &[lo, hi, selected.0, selected.1],
180            );
181            if selected.0.min(selected.1) < lo - slack || selected.0.max(selected.1) > hi + slack {
182                return Err(GeomError::InvalidInput(
183                    "sweep range exceeds trimmed directrix".into(),
184                ));
185            }
186            let out = resolve(graph, *basis, Some(selected), options, depth + 1)?;
187            Ok(if *sense_agreement { out } else { reversed(out) })
188        }
189        // A composite may turn a corner at a joint, which no refinement
190        // removes, so it reports no end tangents.
191        Some(GeometryNode::CurveRelation(CurveRelation::Composite { segments })) => {
192            let mut out = Vec::new();
193            for segment in segments {
194                let (mut child, _) = resolve(graph, segment.curve, None, options, depth + 1)?;
195                if !segment.same_sense {
196                    child.reverse();
197                }
198                stitch(&mut out, child, options.tolerance().linear())?;
199                if out.len() > MAX_POINTS {
200                    return Err(GeomError::BudgetExceeded {
201                        resource: "directrix points",
202                    });
203                }
204            }
205            match range {
206                Some((start, end)) => {
207                    trim_by_length(&out, start, end, options.tolerance().linear())
208                        .map(|points| (points, None))
209                }
210                None => Ok((out, None)),
211            }
212        }
213        // Offsets at stations along a basis curve, sampled (#241).
214        Some(GeometryNode::CurveRelation(
215            relation @ (CurveRelation::OffsetByStations { .. }
216            | CurveRelation::OffsetsAtStations { .. }),
217        )) => crate::station::offset_curve_points(graph, relation, range, options)
218            .map(|points| (points, None)),
219        // A curve placed at a station (#264): its source sampled in its own
220        // coordinates (a 2D one in `z = 0`), then moved rigidly.
221        Some(GeometryNode::InstanceAtStation(placed)) => {
222            let transform = crate::station::placement(graph, id)?.transform;
223            let (points, ends) = match graph.get(placed.source) {
224                Some(GeometryNode::Curve2(curve)) => sample_curve2(curve, range, options)?,
225                _ => resolve(graph, placed.source, range, options, depth + 1).map_err(|error| {
226                    match error {
227                        GeomError::InvalidInput(detail) if detail.contains("not a 3D curve") => {
228                            GeomError::UnsupportedInput {
229                                backend: crate::BACKEND_ID,
230                                operation: axiolid_contracts::Operation::CurveEvaluation,
231                                input: "a 2D curve relation placed at a station, as a sweep \
232                                        directrix: only an atomic 2D curve is lifted",
233                            }
234                        }
235                        other => other,
236                    }
237                })?,
238            };
239            Ok((
240                points
241                    .into_iter()
242                    .map(|p| transform.transform_point3(p))
243                    .collect(),
244                ends.map(|ends| ends.map(|v| transform.transform_vector3(v))),
245            ))
246        }
247        Some(GeometryNode::CurveRelation(_)) => Err(unsupported_curve_evaluation()),
248        Some(_) => Err(GeomError::InvalidInput(format!(
249            "sweep directrix {id:?} is not a 3D curve"
250        ))),
251        None => Err(GeomError::InvalidInput(format!(
252            "directrix {id:?} is outside the graph"
253        ))),
254    }
255}
256
257fn unsupported_curve_evaluation() -> GeomError {
258    GeomError::Unsupported {
259        backend: axiolid_contracts::BackendId::new("scalar-compile"),
260        operation: axiolid_contracts::Operation::CurveEvaluation,
261    }
262}
263
264pub(crate) fn parameter(
265    selectors: &[TrimSelector],
266    preference: TrimmingPreference,
267    label: &str,
268    basis: &axiolid_curve::Curve3,
269    sense: bool,
270    tolerance: axiolid_core::Tolerance,
271) -> GeomResult<Scalar> {
272    // A Cartesian selector names a POINT. Some formats can only state a trim
273    // that way -- a three-point arc knows its endpoints, not their parameters
274    // -- so the point is inverted against the basis rather than refused.
275    // Inversion is exact or it refuses; it never projects an off-curve point
276    // (see `point_parameter`: a B-spline's by #304's point-bound inversion).
277    // An arc length is a parameter-kind selector, resolved against the basis
278    // by quadrature (`axiolid_reference::arc_parameter`); its failure is
279    // reported, never replaced by another selector.
280    let first_parameter = || -> GeomResult<Option<Scalar>> {
281        selectors
282            .iter()
283            .find(|selector| is_parameter_kind(selector))
284            .map_or(Ok(None), |selector| {
285                parameter_kind(selector, Some(basis), sense, label)
286            })
287    };
288    let mut refusal = None;
289    let mut invert = || {
290        first_point(
291            selectors,
292            |selector| match selector {
293                TrimSelector::Point3(point) => Some(*point),
294                _ => None,
295            },
296            StationCurve::Three(basis),
297            tolerance,
298            &mut refusal,
299        )
300    };
301    let selected = match preference {
302        TrimmingPreference::Parameter => first_parameter()?,
303        TrimmingPreference::Unspecified => match selectors.first() {
304            Some(selector) if is_parameter_kind(selector) => {
305                parameter_kind(selector, Some(basis), sense, label)?
306            }
307            _ => invert(),
308        },
309        TrimmingPreference::Cartesian => match invert() {
310            Some(value) => Some(value),
311            None => first_parameter()?,
312        },
313    };
314    selected
315        .filter(|value| value.is_finite())
316        .ok_or_else(|| match refusal {
317            Some(refusal) => refusal,
318            None => GeomError::InvalidInput(format!(
319                "trimmed directrix {label} needs a parameter selector, or a point \
320                 selector that lies on the basis curve"
321            )),
322        })
323}
324
325/// Whether a selector names a position by a measure along the curve
326/// (a parameter or an arc length) rather than by a point.
327pub(crate) fn is_parameter_kind(selector: &TrimSelector) -> bool {
328    matches!(
329        selector,
330        TrimSelector::Parameter(_) | TrimSelector::ArcLength(_)
331    )
332}
333
334/// A parameter-kind selector read as a basis parameter.
335///
336/// An arc length is measured from the basis parameter `0` in the trim's
337/// sense, to the tolerance `axiolid_reference::arc_parameter` states. It
338/// needs an analytic basis to measure along: on a relation basis it is
339/// refused by name rather than read as a parameter.
340fn parameter_kind(
341    selector: &TrimSelector,
342    basis: Option<&axiolid_curve::Curve3>,
343    sense: bool,
344    label: &str,
345) -> GeomResult<Option<Scalar>> {
346    match selector {
347        TrimSelector::Parameter(value) => Ok(Some(*value)),
348        TrimSelector::ArcLength(length) => {
349            let Some(curve) = basis else {
350                return Err(GeomError::InvalidInput(format!(
351                    "trimmed directrix {label} is an arc-length selector, but its basis \
352                     is a curve relation with no single curve to measure along"
353                )));
354            };
355            if !length.is_finite() {
356                return Err(GeomError::InvalidInput(format!(
357                    "trimmed directrix {label} arc length must be finite"
358                )));
359            }
360            let signed = if sense { *length } else { -*length };
361            axiolid_reference::arc_parameter::parameter_at_arc_length3(curve, 0.0, signed)
362                .map(Some)
363                .map_err(|error| {
364                    GeomError::InvalidInput(format!(
365                        "trimmed directrix {label} arc length {length} does not resolve on \
366                         its basis: {error}"
367                    ))
368                })
369        }
370        _ => Ok(None),
371    }
372}
373
374/// Parameter selectors only, for a basis with no invertible analytic curve.
375pub(crate) fn parameter_only(
376    selectors: &[TrimSelector],
377    preference: TrimmingPreference,
378    label: &str,
379) -> GeomResult<Scalar> {
380    let selected = match preference {
381        TrimmingPreference::Parameter | TrimmingPreference::Unspecified => selectors
382            .iter()
383            .find(|selector| is_parameter_kind(selector))
384            .map_or(Ok(None), |selector| {
385                parameter_kind(selector, None, true, label)
386            })?,
387        // The basis is a relation, so there is no analytic curve to invert a
388        // point against. Refusing names that, rather than silently using a
389        // parameter the file did not designate as authoritative.
390        TrimmingPreference::Cartesian => None,
391    };
392    selected.filter(|value| value.is_finite()).ok_or_else(|| {
393        GeomError::InvalidInput(format!(
394            "trimmed directrix {label} needs a finite parameter selector; its \
395             basis is a curve relation, so a point selector cannot be inverted"
396        ))
397    })
398}
399
400/// The basis parameter a trim's point selector names (#306), a 2D
401/// curve's point lifted to `z = 0`.
402///
403/// A B-spline has no closed-form inverse, so its point is resolved by the
404/// one inversion a curve path's point bound is (#304,
405/// [`StationCurve::parameter_at_bound`]): the same tolerance
406/// ([`axiolid_reference::station::point_bound_tolerance`], the point's
407/// own, not `tolerance`), the same sampling and Newton refinement, and the
408/// same refusals, naming
409/// [`axiolid_reference::station::PATH_POINT_OFF_CURVE`] and
410/// [`axiolid_reference::station::PATH_POINT_AMBIGUOUS`]. A trim of a
411/// B-spline by points therefore spans exactly the parameters a piece
412/// bounded by `PathBound::Point` does, bitwise. Every other family is
413/// inverted exactly to `tolerance` (`axiolid_reference::curve::invert2`,
414/// `invert3`) or refused.
415///
416/// # Errors
417///
418/// The inversion's refusal.
419pub(crate) fn point_parameter(
420    curve: StationCurve<'_>,
421    point: Point3,
422    tolerance: axiolid_core::Tolerance,
423) -> GeomResult<Scalar> {
424    match curve {
425        StationCurve::Two(Curve2::BSpline(_)) | StationCurve::Three(Curve3::BSpline(_)) => {
426            curve.parameter_at_bound(PathBound::Point(point))
427        }
428        StationCurve::Two(curve) => {
429            axiolid_reference::curve::invert2(curve, Point2::new(point.x, point.y), tolerance)
430        }
431        StationCurve::Three(curve) => axiolid_reference::curve::invert3(curve, point, tolerance),
432        _ => Err(GeomError::UnsupportedInput {
433            backend: crate::BACKEND_ID,
434            operation: axiolid_contracts::Operation::CurveEvaluation,
435            input: "a trim's point selector on a curve this compiler does not invert",
436        }),
437    }
438}
439
440/// The parameter of the first point selector (as `point` reads one) that
441/// [`point_parameter`] resolves on `curve`, if any. A B-spline's refusal is
442/// kept in `refusal` (the first one), so that a trim with no other
443/// selector to fall back on is refused by its name; another family's
444/// failure is not kept, and reads as no point selector resolving.
445pub(crate) fn first_point(
446    selectors: &[TrimSelector],
447    point: impl Fn(&TrimSelector) -> Option<Point3>,
448    curve: StationCurve<'_>,
449    tolerance: axiolid_core::Tolerance,
450    refusal: &mut Option<GeomError>,
451) -> Option<Scalar> {
452    let spline = matches!(
453        curve,
454        StationCurve::Two(Curve2::BSpline(_)) | StationCurve::Three(Curve3::BSpline(_))
455    );
456    selectors
457        .iter()
458        .filter_map(point)
459        .find_map(|p| match point_parameter(curve, p, tolerance) {
460            Ok(t) => Some(t),
461            Err(error) => {
462                if spline && refusal.is_none() {
463                    *refusal = Some(error);
464                }
465                None
466            }
467        })
468}
469
470/// Parameter period of a closed conic basis; `None` for anything else.
471fn period_of(curve: &axiolid_curve::Curve3) -> Option<Scalar> {
472    match curve {
473        axiolid_curve::Curve3::Circle(_) | axiolid_curve::Curve3::Ellipse(_) => {
474            Some(core::f64::consts::TAU)
475        }
476        _ => None,
477    }
478}
479
480/// Relative slack for "at most one period": authoring tools write a quarter
481/// bend as `(3pi/2, 2pi + 1e-15)`, and that must stay a quarter, not wrap.
482const PERIOD_SLACK: Scalar = 1e-9;
483
484/// Sample a trim of a periodic basis in its UNWRAPPED basis interval.
485///
486/// The trimmed curve runs from `a` forward (sense) or backward (against) to
487/// the next occurrence of `b`, so its basis interval is `[a, a + span]` or
488/// `[a - span, a]`, which may extend past the natural `[0, period]`; a conic
489/// evaluates the same there. Returned in increasing basis parameter; the
490/// caller reverses for `!sense`, as for every other basis.
491///
492/// A sweep `range` is read in the trimmed curve's own parameter, which is
493/// the basis parameter: each end is taken modulo the period into the
494/// unwrapped interval, so `(330, 30)`, `(330, 390)` and `(-30, 30)` degrees
495/// all name the same sub-arc of a `315 -> 45` trim. An end that lands on no
496/// point of the arc is refused, as a range outside any trim is.
497fn sample_periodic_trim(
498    curve: &axiolid_curve::Curve3,
499    period: Scalar,
500    a: Scalar,
501    b: Scalar,
502    sense: bool,
503    range: Option<(Scalar, Scalar)>,
504    options: &ExecutionOptions,
505) -> GeomResult<Resolved> {
506    let (lo, hi) = periodic_trim_interval(
507        period,
508        a,
509        b,
510        sense,
511        range,
512        conic_slack(curve, options, a, b),
513    )?;
514    let points = axiolid_reference::curve::flatten3(
515        curve,
516        axiolid_core::Interval { start: lo, end: hi },
517        crate::compiler::chord_error(options),
518        MAX_FLATTEN_DEPTH,
519    )?;
520    Ok((points, smooth_ends(curve, lo, hi)))
521}
522
523/// The unwrapped basis interval `[lo, hi]` of a periodic trim, narrowed to
524/// a sweep `range` when one is given (see [`sample_periodic_trim`]), read to
525/// `slack` parameter units of its ends ([`slack_in_parameter`]).
526pub(crate) fn periodic_trim_interval(
527    period: Scalar,
528    a: Scalar,
529    b: Scalar,
530    sense: bool,
531    range: Option<(Scalar, Scalar)>,
532    slack: Scalar,
533) -> GeomResult<(Scalar, Scalar)> {
534    let arc = periodic_arc(period, a, b, sense);
535    match range {
536        None => Ok(arc),
537        Some(range) => range_on_periodic_arc(arc, period, range, slack),
538    }
539}
540
541/// The unwrapped basis interval `[lo, hi]` a trim of a periodic basis runs
542/// over: from `a` the way `sense` says to the next occurrence of `b`,
543/// wrapping if it must (see [`sample_periodic_trim`]).
544pub(crate) fn periodic_arc(period: Scalar, a: Scalar, b: Scalar, sense: bool) -> (Scalar, Scalar) {
545    let travelled = if sense { b - a } else { a - b };
546    let span = if travelled > 0.0 && travelled <= period * (1.0 + PERIOD_SLACK) {
547        travelled
548    } else {
549        match travelled.rem_euclid(period) {
550            // `a` and `b` differ by whole turns: the trim is a full turn.
551            0.0 => period,
552            wrapped => wrapped,
553        }
554    };
555    if sense {
556        (a, a + span)
557    } else {
558        (a - span, a)
559    }
560}
561
562/// The fallback slack within which a parameter may lie beyond the span it
563/// is read against, where no speed bound of the curve is at hand: relative
564/// to the parameters' magnitude, as the station measures admit a distance
565/// (`1e-12 * max(1, |x|)`). It is as tight as a parameter can be asked to
566/// be, so it fails closed.
567pub(crate) fn parameter_slack(values: &[Scalar]) -> Scalar {
568    1e-12 * values.iter().fold(1.0, |m: Scalar, v| m.max(v.abs()))
569}
570
571/// A certified upper bound of the speed `|c'|` of a 2D curve over its
572/// domain: a circle's radius, a line's direction, an ellipse's larger semi
573/// axis, else the evaluator's derivative bound; `None` where there is none.
574pub(crate) fn speed_bound2(curve: &Curve2) -> Option<Scalar> {
575    match curve {
576        Curve2::Circle(circle) => Some(circle.radius),
577        Curve2::Line(line) => Some(line.direction.length()),
578        Curve2::Ellipse(e) => Some(e.semi_axis_x.max(e.semi_axis_y)),
579        _ => {
580            let domain = axiolid_reference::curve::domain2(curve);
581            axiolid_reference::bound::curve_derivative_bounds2(curve, domain.start, domain.end)
582                .map(|bounds| bounds.first)
583        }
584    }
585}
586
587/// [`speed_bound2`] for a 3D curve.
588pub(crate) fn speed_bound3(curve: &Curve3) -> Option<Scalar> {
589    match curve {
590        Curve3::Circle(circle) => Some(circle.radius),
591        Curve3::Line(line) => Some(line.direction.length()),
592        Curve3::Ellipse(e) => Some(e.semi_axis_x.max(e.semi_axis_y)),
593        _ => {
594            let domain = axiolid_reference::curve::domain3(curve);
595            axiolid_reference::bound::curve_derivative_bounds3(curve, domain.start, domain.end)
596                .map(|bounds| bounds.first)
597        }
598    }
599}
600
601/// The slack, in parameter units, within which a parameter may lie beyond
602/// the span it is read against: the linear `tolerance` over a certified
603/// upper bound of the carrier's speed, so it is the tolerance along the
604/// curve, as an exporter's noise is. Without a tolerance (a station basis
605/// is read from the graph alone) or a speed bound it is the relative
606/// [`parameter_slack`] of `values`.
607pub(crate) fn slack_in_parameter(
608    tolerance: Option<Scalar>,
609    speed: Option<Scalar>,
610    values: &[Scalar],
611) -> Scalar {
612    match (tolerance, speed) {
613        (Some(tolerance), Some(speed)) if speed.is_finite() && speed > 0.0 => tolerance / speed,
614        _ => parameter_slack(values),
615    }
616}
617
618/// [`slack_in_parameter`] for the periodic trim of a conic `curve`.
619pub(crate) fn conic_slack(
620    curve: &Curve3,
621    options: &ExecutionOptions,
622    a: Scalar,
623    b: Scalar,
624) -> Scalar {
625    slack_in_parameter(
626        Some(options.tolerance().linear()),
627        speed_bound3(curve),
628        &[a, b],
629    )
630}
631
632/// The speed bound of the curve a directrix trim's basis `id` runs along,
633/// through trims and surface curves.
634fn relation_speed(graph: &GeometryGraph, id: NodeId, depth: usize) -> Option<Scalar> {
635    if depth > MAX_DEPTH {
636        return None;
637    }
638    match graph.get(id) {
639        Some(GeometryNode::Curve3(curve)) => speed_bound3(curve),
640        Some(GeometryNode::CurveRelation(CurveRelation::Trimmed { basis, .. })) => {
641            relation_speed(graph, *basis, depth + 1)
642        }
643        Some(GeometryNode::CurveRelation(CurveRelation::SurfaceCurve {
644            curve_3d,
645            master: MasterRepresentation::Curve3d,
646            ..
647        })) => relation_speed(graph, *curve_3d, depth + 1),
648        _ => None,
649    }
650}
651
652/// A `range` read in the parameter of a periodic arc `[lo, hi]`: each end
653/// is taken modulo the `period` into the arc, within `slack` of its ends,
654/// and the result is the sorted pair. A full turn ends where it starts, so
655/// there an end that lands on `lo` is read as `hi` when it is the larger end
656/// asked: `[pi, 2 pi]` of a full turn is its far half, not its near one.
657pub(crate) fn range_on_periodic_arc(
658    (lo, hi): (Scalar, Scalar),
659    period: Scalar,
660    (start, end): (Scalar, Scalar),
661    slack: Scalar,
662) -> GeomResult<(Scalar, Scalar)> {
663    if !(start.is_finite() && end.is_finite()) {
664        return Err(GeomError::InvalidInput(
665            "sweep parameter range must be finite".into(),
666        ));
667    }
668    if start == end {
669        return Err(GeomError::Degenerate(
670            "sweep parameter range is empty".into(),
671        ));
672    }
673    // The end in the arc, and whether it landed on `lo`.
674    let into_arc = |value: Scalar| -> GeomResult<(Scalar, bool)> {
675        let shifted = lo + (value - lo).rem_euclid(period);
676        // Just below `lo`, a value wraps to just below `lo + period`:
677        // read it as `lo` rather than refuse a rounding residue.
678        let shifted = if shifted > lo + period - slack {
679            lo
680        } else {
681            shifted
682        };
683        if shifted > hi + slack {
684            return Err(GeomError::InvalidInput(
685                "sweep range exceeds trimmed directrix".into(),
686            ));
687        }
688        Ok((shifted.min(hi), shifted == lo))
689    };
690    let ((mut s, s_lo), (mut e, e_lo)) = (into_arc(start)?, into_arc(end)?);
691    if hi - lo >= period * (1.0 - PERIOD_SLACK) && !(s_lo && e_lo) {
692        if s_lo && start > end {
693            s = hi;
694        } else if e_lo && end > start {
695            e = hi;
696        }
697    }
698    // An end ON the trim's far end maps back to `lo` when the arc is
699    // a full turn; a range is never empty by that accident.
700    let (s, e) = if s == e { (lo, hi) } else { (s, e) };
701    Ok((s.min(e), s.max(e)))
702}
703
704fn sample_curve(
705    curve: &axiolid_curve::Curve3,
706    range: Option<(Scalar, Scalar)>,
707    options: &ExecutionOptions,
708) -> GeomResult<Resolved> {
709    let domain = sweep_domain(
710        axiolid_reference::curve::domain3(curve),
711        matches!(curve, axiolid_curve::Curve3::Line(_)),
712        range,
713        options,
714    )?;
715    if elevated::is_elevated(curve) {
716        elevated::check_bounded(domain)?;
717    }
718    let points = axiolid_reference::curve::flatten3(
719        curve,
720        domain,
721        crate::compiler::chord_error(options),
722        MAX_FLATTEN_DEPTH,
723    )?;
724    Ok((points, smooth_ends(curve, domain.start, domain.end)))
725}
726
727/// A 2D curve sampled as [`sample_curve`] samples a 3D one, lying in
728/// `z = 0` (#264: the source of a curve placed at a station). It reports
729/// no end tangents, so a sweep along it is swept as sampled.
730fn sample_curve2(
731    curve: &axiolid_curve::Curve2,
732    range: Option<(Scalar, Scalar)>,
733    options: &ExecutionOptions,
734) -> GeomResult<Resolved> {
735    let domain = sweep_domain(
736        axiolid_reference::curve::domain2(curve),
737        matches!(curve, axiolid_curve::Curve2::Line(_)),
738        range,
739        options,
740    )?;
741    let points = axiolid_reference::curve::flatten2(
742        curve,
743        domain,
744        crate::compiler::chord_error(options),
745        MAX_FLATTEN_DEPTH,
746    )?;
747    Ok((
748        points
749            .into_iter()
750            .map(|p| Point3::new(p.x, p.y, 0.0))
751            .collect(),
752        None,
753    ))
754}
755
756/// The parameter interval a sweep `range` selects of a curve whose
757/// natural domain is `natural`; an `unbounded` curve (a line) takes the
758/// range as given.
759fn sweep_domain(
760    natural: axiolid_core::Interval,
761    unbounded: bool,
762    range: Option<(Scalar, Scalar)>,
763    options: &ExecutionOptions,
764) -> GeomResult<axiolid_core::Interval> {
765    Ok(match range {
766        None => natural,
767        Some((start, end)) => {
768            if !(start.is_finite() && end.is_finite()) {
769                return Err(GeomError::InvalidInput(
770                    "sweep parameter range must be finite".into(),
771                ));
772            }
773            if start == end {
774                return Err(GeomError::Degenerate(
775                    "sweep parameter range is empty".into(),
776                ));
777            }
778            let mut lo = natural.start.min(natural.end);
779            let mut hi = natural.start.max(natural.end);
780            let rlo = start.min(end);
781            let rhi = start.max(end);
782            if unbounded {
783                lo = rlo;
784                hi = rhi;
785            }
786            let slack = options.tolerance().linear();
787            if rlo < lo - slack || rhi > hi + slack {
788                return Err(GeomError::InvalidInput(
789                    "sweep parameter range falls outside curve domain".into(),
790                ));
791            }
792            axiolid_core::Interval {
793                start: rlo.max(lo),
794                end: rhi.min(hi),
795            }
796        }
797    })
798}
799
800fn stitch(target: &mut Vec<Point3>, mut child: Vec<Point3>, tolerance: Scalar) -> GeomResult<()> {
801    if target.is_empty() {
802        target.append(&mut child);
803        return Ok(());
804    }
805    let gap = target
806        .last()
807        .unwrap()
808        .distance(*child.first().ok_or_else(|| {
809            GeomError::Degenerate("composite directrix segment has no points".into())
810        })?);
811    if gap > tolerance {
812        return Err(GeomError::InvalidInput(format!(
813            "composite directrix has a {gap} unit gap"
814        )));
815    }
816    child.remove(0);
817    target.append(&mut child);
818    Ok(())
819}
820
821fn trim_by_length(
822    points: &[Point3],
823    start: Scalar,
824    end: Scalar,
825    tolerance: Scalar,
826) -> GeomResult<Vec<Point3>> {
827    if !(start.is_finite() && end.is_finite()) {
828        return Err(GeomError::InvalidInput(
829            "composite range must be finite".into(),
830        ));
831    }
832    if start == end {
833        return Err(GeomError::Degenerate("composite range is empty".into()));
834    }
835    let mut cumulative = Vec::with_capacity(points.len());
836    cumulative.push(0.0);
837    for edge in points.windows(2) {
838        let next = cumulative.last().copied().unwrap() + edge[0].distance(edge[1]);
839        cumulative.push(next);
840    }
841    let total = cumulative.last().copied().unwrap_or(0.0);
842    let lo = start.min(end);
843    let hi = start.max(end);
844    if lo < -tolerance || hi > total + tolerance {
845        return Err(GeomError::InvalidInput(format!(
846            "composite range ({start}, {end}) exceeds length {total}"
847        )));
848    }
849    let lo = lo.max(0.0);
850    let hi = hi.min(total);
851    let mut out = vec![point_at_length(points, &cumulative, lo)?];
852    for (&distance, &point) in cumulative
853        .iter()
854        .zip(points)
855        .skip(1)
856        .take(points.len().saturating_sub(2))
857    {
858        if distance > lo && distance < hi {
859            out.push(point);
860        }
861    }
862    out.push(point_at_length(points, &cumulative, hi)?);
863    if start > end {
864        out.reverse();
865    }
866    Ok(out)
867}
868
869fn point_at_length(points: &[Point3], cumulative: &[Scalar], target: Scalar) -> GeomResult<Point3> {
870    for (index, limits) in cumulative.windows(2).enumerate() {
871        if target <= limits[1] {
872            let span = limits[1] - limits[0];
873            if span == 0.0 {
874                return Ok(points[index]);
875            }
876            return Ok(points[index].lerp(points[index + 1], (target - limits[0]) / span));
877        }
878    }
879    points
880        .last()
881        .copied()
882        .ok_or_else(|| GeomError::Degenerate("directrix has no points".into()))
883}
884
885/// A directrix an exact swept disk can follow: one straight segment or one
886/// circular arc (#223). Returned by [`crate::exact_directrix`].
887#[derive(Debug, Clone, PartialEq)]
888#[non_exhaustive]
889pub enum ExactDirectrix {
890    /// From the first point to the second.
891    Segment(Point3, Point3),
892    /// Over an angle span of the circle, start to end.
893    Arc(axiolid_curve::Circle3, axiolid_core::Interval),
894}
895
896/// Resolve a directrix to one segment or one arc, read with the same
897/// parameter conventions as [`points`]; anything with corners, or curved
898/// other than a circle, is refused by name through `unsupported`.
899pub(crate) fn exact(
900    graph: &GeometryGraph,
901    id: NodeId,
902    range: Option<(Scalar, Scalar)>,
903    options: &ExecutionOptions,
904    unsupported: fn(&'static str) -> GeomError,
905) -> GeomResult<ExactDirectrix> {
906    exact_at(graph, id, range, options, unsupported, 0)
907}
908
909fn finite_range((start, end): (Scalar, Scalar)) -> GeomResult<(Scalar, Scalar)> {
910    if !(start.is_finite() && end.is_finite()) {
911        return Err(GeomError::InvalidInput(
912            "sweep parameter range must be finite".into(),
913        ));
914    }
915    if start == end {
916        return Err(GeomError::Degenerate(
917            "sweep parameter range is empty".into(),
918        ));
919    }
920    Ok((start, end))
921}
922
923fn exact_at(
924    graph: &GeometryGraph,
925    id: NodeId,
926    range: Option<(Scalar, Scalar)>,
927    options: &ExecutionOptions,
928    unsupported: fn(&'static str) -> GeomError,
929    depth: usize,
930) -> GeomResult<ExactDirectrix> {
931    use axiolid_curve::Curve3;
932    use core::f64::consts::TAU;
933    if depth > MAX_DEPTH {
934        return Err(GeomError::BudgetExceeded {
935            resource: "directrix relation depth",
936        });
937    }
938    let slack = options.tolerance().linear();
939    let other = "exact swept disk along a directrix other than one segment or one arc";
940    match graph.get(id) {
941        Some(GeometryNode::Curve3(Curve3::Line(line))) => {
942            let range =
943                range.ok_or_else(|| unsupported("exact swept disk along an unbounded line"))?;
944            let (start, end) = finite_range(range)?;
945            Ok(ExactDirectrix::Segment(
946                line.origin + line.direction * start,
947                line.origin + line.direction * end,
948            ))
949        }
950        Some(GeometryNode::Curve3(Curve3::Polyline(polyline)))
951            if polyline.points.len() == 2 && !polyline.closed && range.is_none() =>
952        {
953            Ok(ExactDirectrix::Segment(
954                polyline.points[0],
955                polyline.points[1],
956            ))
957        }
958        Some(GeometryNode::Curve3(Curve3::Circle(circle))) => {
959            let span = match range {
960                None => axiolid_core::Interval::new(0.0, TAU),
961                Some(range) => {
962                    // As the mesh path reads it: an increasing span inside
963                    // the circle's natural domain `[0, 2 pi]`.
964                    let (start, end) = finite_range(range)?;
965                    let (lo, hi) = (start.min(end), start.max(end));
966                    if lo < -slack || hi > TAU + slack {
967                        return Err(GeomError::InvalidInput(
968                            "sweep parameter range falls outside curve domain".into(),
969                        ));
970                    }
971                    axiolid_core::Interval::new(lo.max(0.0), hi.min(TAU))
972                }
973            };
974            Ok(ExactDirectrix::Arc(*circle, span))
975        }
976        // A straight plan under a constant grade is a segment (#252).
977        Some(GeometryNode::Curve3(curve)) => {
978            elevated::exact_segment(curve, range, options, unsupported)
979                .unwrap_or_else(|| Err(unsupported(other)))
980        }
981        Some(GeometryNode::CurveRelation(CurveRelation::SurfaceCurve {
982            curve_3d,
983            master: MasterRepresentation::Curve3d,
984            ..
985        })) => exact_at(graph, *curve_3d, range, options, unsupported, depth + 1),
986        Some(GeometryNode::CurveRelation(CurveRelation::Composite { segments }))
987            if segments.len() == 1 && range.is_none() =>
988        {
989            // One segment has no corners, and its sense does not change
990            // the swept solid.
991            exact_at(
992                graph,
993                segments[0].curve,
994                None,
995                options,
996                unsupported,
997                depth + 1,
998            )
999        }
1000        Some(GeometryNode::CurveRelation(CurveRelation::Trimmed {
1001            basis,
1002            start,
1003            end,
1004            sense_agreement,
1005            preference,
1006        })) => {
1007            let Some(GeometryNode::Curve3(curve)) = graph.get(*basis) else {
1008                return Err(unsupported(
1009                    "exact swept disk along a trim of a curve relation",
1010                ));
1011            };
1012            let a = parameter(
1013                start,
1014                *preference,
1015                "start",
1016                curve,
1017                *sense_agreement,
1018                options.tolerance(),
1019            )?;
1020            let b = parameter(
1021                end,
1022                *preference,
1023                "end",
1024                curve,
1025                *sense_agreement,
1026                options.tolerance(),
1027            )?;
1028            if a == b {
1029                return Err(GeomError::Degenerate(
1030                    "trimmed directrix has an empty interval".into(),
1031                ));
1032            }
1033            match curve {
1034                Curve3::Line(line) => {
1035                    let (lo, hi) = (a.min(b), a.max(b));
1036                    let (s, e) = match range {
1037                        None => (a, b),
1038                        Some(range) => {
1039                            let (s, e) = finite_range(range)?;
1040                            if s.min(e) < lo - slack || s.max(e) > hi + slack {
1041                                return Err(GeomError::InvalidInput(
1042                                    "sweep range exceeds trimmed directrix".into(),
1043                                ));
1044                            }
1045                            (s.clamp(lo, hi), e.clamp(lo, hi))
1046                        }
1047                    };
1048                    Ok(ExactDirectrix::Segment(
1049                        line.origin + line.direction * s,
1050                        line.origin + line.direction * e,
1051                    ))
1052                }
1053                Curve3::Circle(circle) => {
1054                    let (lo, hi) = periodic_trim_interval(
1055                        TAU,
1056                        a,
1057                        b,
1058                        *sense_agreement,
1059                        range,
1060                        conic_slack(curve, options, a, b),
1061                    )?;
1062                    Ok(ExactDirectrix::Arc(
1063                        *circle,
1064                        axiolid_core::Interval::new(lo, hi),
1065                    ))
1066                }
1067                _ => Err(unsupported(
1068                    "exact swept disk along a trim of a curve other than a line or circle",
1069                )),
1070            }
1071        }
1072        Some(GeometryNode::CurveRelation(_)) => Err(unsupported(other)),
1073        Some(GeometryNode::InstanceAtStation(_)) => Err(unsupported(
1074            "exact swept disk along a curve placed at a station",
1075        )),
1076        Some(_) => Err(GeomError::InvalidInput(format!(
1077            "sweep directrix {id:?} is not a 3D curve"
1078        ))),
1079        None => Err(GeomError::InvalidInput(format!(
1080            "directrix {id:?} is outside the graph"
1081        ))),
1082    }
1083}
1084
1085/// What a sweep's directrix is, for its deviation report (#232).
1086#[derive(Debug, Clone, PartialEq)]
1087pub(crate) enum DirectrixKind {
1088    /// One straight segment.
1089    Segment,
1090    /// One circular arc.
1091    Arc(axiolid_curve::Circle3),
1092    /// One smooth curve over a parameter span, as the mesh path samples it:
1093    /// an ellipse or a B-spline.
1094    Smooth(axiolid_curve::Curve3, axiolid_core::Interval),
1095    /// Anything else, named.
1096    Other(&'static str),
1097}
1098
1099/// Classify a directrix the way [`sampled`] reads it.
1100///
1101/// A segment or an arc is what [`exact`] resolves. An ellipse or a
1102/// B-spline, alone, trimmed or read through a curve-3D surface curve, is
1103/// [`DirectrixKind::Smooth`] over the span [`sampled`] flattens; a trim of
1104/// a periodic ellipse uses the same unwrapped interval. Everything else
1105/// (polylines, composites, relations of relations) is named.
1106pub(crate) fn kind(
1107    graph: &GeometryGraph,
1108    id: NodeId,
1109    range: Option<(Scalar, Scalar)>,
1110    options: &ExecutionOptions,
1111) -> GeomResult<DirectrixKind> {
1112    fn marker(_: &'static str) -> GeomError {
1113        GeomError::Degenerate("not one segment or one arc".to_owned())
1114    }
1115    if let Ok(exact) = exact(graph, id, range, options, marker) {
1116        return Ok(match exact {
1117            ExactDirectrix::Segment(..) => DirectrixKind::Segment,
1118            ExactDirectrix::Arc(circle, _) => DirectrixKind::Arc(circle),
1119        });
1120    }
1121    smooth_kind(graph, id, range, options, 0)
1122}
1123
1124fn smooth_kind(
1125    graph: &GeometryGraph,
1126    id: NodeId,
1127    range: Option<(Scalar, Scalar)>,
1128    options: &ExecutionOptions,
1129    depth: usize,
1130) -> GeomResult<DirectrixKind> {
1131    use axiolid_curve::Curve3;
1132    if depth > MAX_DEPTH {
1133        return Err(GeomError::BudgetExceeded {
1134            resource: "directrix relation depth",
1135        });
1136    }
1137    let smooth = |curve: &Curve3| {
1138        matches!(curve, Curve3::Ellipse(_) | Curve3::BSpline(_)) || elevated::is_elevated(curve)
1139    };
1140    // An elevated curve is one smooth curve only where its grade does not
1141    // break (#252).
1142    let classified = |curve: &Curve3, span: axiolid_core::Interval| {
1143        if elevated::is_elevated(curve) && elevated::has_corner(curve, span.start, span.end) {
1144            DirectrixKind::Other("elevated directrix with a grade break")
1145        } else {
1146            DirectrixKind::Smooth(curve.clone(), span)
1147        }
1148    };
1149    Ok(match graph.get(id) {
1150        Some(GeometryNode::Curve3(curve)) if smooth(curve) => {
1151            classified(curve, clamped_span(curve, range)?)
1152        }
1153        Some(GeometryNode::Curve3(Curve3::Polyline(_))) => {
1154            DirectrixKind::Other("polyline directrix")
1155        }
1156        Some(GeometryNode::Curve3(_)) => DirectrixKind::Other("directrix curve family"),
1157        Some(GeometryNode::CurveRelation(CurveRelation::SurfaceCurve {
1158            curve_3d,
1159            master: MasterRepresentation::Curve3d,
1160            ..
1161        })) => smooth_kind(graph, *curve_3d, range, options, depth + 1)?,
1162        Some(GeometryNode::CurveRelation(CurveRelation::Trimmed {
1163            basis,
1164            start,
1165            end,
1166            sense_agreement,
1167            preference,
1168        })) => {
1169            let Some(GeometryNode::Curve3(curve)) = graph.get(*basis) else {
1170                return Ok(DirectrixKind::Other("trim of a curve relation"));
1171            };
1172            if !smooth(curve) {
1173                return Ok(DirectrixKind::Other("trimmed directrix curve family"));
1174            }
1175            let a = parameter(
1176                start,
1177                *preference,
1178                "start",
1179                curve,
1180                *sense_agreement,
1181                options.tolerance(),
1182            )?;
1183            let b = parameter(
1184                end,
1185                *preference,
1186                "end",
1187                curve,
1188                *sense_agreement,
1189                options.tolerance(),
1190            )?;
1191            if let Some(period) = period_of(curve) {
1192                let (lo, hi) = periodic_trim_interval(
1193                    period,
1194                    a,
1195                    b,
1196                    *sense_agreement,
1197                    range,
1198                    conic_slack(curve, options, a, b),
1199                )?;
1200                DirectrixKind::Smooth(curve.clone(), axiolid_core::Interval::new(lo, hi))
1201            } else {
1202                let (s, e) = range.unwrap_or((a, b));
1203                classified(curve, clamped_span(curve, Some((s, e)))?)
1204            }
1205        }
1206        Some(GeometryNode::CurveRelation(CurveRelation::Composite { .. })) => {
1207            DirectrixKind::Other("composite directrix")
1208        }
1209        Some(GeometryNode::CurveRelation(_)) => DirectrixKind::Other("directrix relation"),
1210        Some(GeometryNode::InstanceAtStation(_)) => {
1211            DirectrixKind::Other("directrix placed at a station")
1212        }
1213        _ => DirectrixKind::Other("not a 3D curve"),
1214    })
1215}
1216
1217/// The span [`sample_curve`] flattens: the natural domain, narrowed to a
1218/// range when one is given.
1219fn clamped_span(
1220    curve: &axiolid_curve::Curve3,
1221    range: Option<(Scalar, Scalar)>,
1222) -> GeomResult<axiolid_core::Interval> {
1223    let natural = axiolid_reference::curve::domain3(curve);
1224    Ok(match range {
1225        None => natural,
1226        Some((start, end)) => {
1227            let (start, end) = finite_range((start, end))?;
1228            let lo = natural.start.min(natural.end);
1229            let hi = natural.start.max(natural.end);
1230            axiolid_core::Interval::new(start.min(end).max(lo), start.max(end).min(hi))
1231        }
1232    })
1233}