1use 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
30pub(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
57type Resolved = (Vec<Point3>, Option<[Vec3; 2]>);
60
61fn reversed((mut points, ends): Resolved) -> Resolved {
64 points.reverse();
65 (points, ends.map(|[start, end]| [-end, -start]))
66}
67
68fn 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 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 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 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 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 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 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 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 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
325pub(crate) fn is_parameter_kind(selector: &TrimSelector) -> bool {
328 matches!(
329 selector,
330 TrimSelector::Parameter(_) | TrimSelector::ArcLength(_)
331 )
332}
333
334fn 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
374pub(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 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
400pub(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
440pub(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
470fn 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
480const PERIOD_SLACK: Scalar = 1e-9;
483
484fn 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
523pub(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
541pub(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 0.0 => period,
552 wrapped => wrapped,
553 }
554 };
555 if sense {
556 (a, a + span)
557 } else {
558 (a - span, a)
559 }
560}
561
562pub(crate) fn parameter_slack(values: &[Scalar]) -> Scalar {
568 1e-12 * values.iter().fold(1.0, |m: Scalar, v| m.max(v.abs()))
569}
570
571pub(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
587pub(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
601pub(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
618pub(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
632fn 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
652pub(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 let into_arc = |value: Scalar| -> GeomResult<(Scalar, bool)> {
675 let shifted = lo + (value - lo).rem_euclid(period);
676 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 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
727fn 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
756fn 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#[derive(Debug, Clone, PartialEq)]
888#[non_exhaustive]
889pub enum ExactDirectrix {
890 Segment(Point3, Point3),
892 Arc(axiolid_curve::Circle3, axiolid_core::Interval),
894}
895
896pub(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 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 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 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#[derive(Debug, Clone, PartialEq)]
1087pub(crate) enum DirectrixKind {
1088 Segment,
1090 Arc(axiolid_curve::Circle3),
1092 Smooth(axiolid_curve::Curve3, axiolid_core::Interval),
1095 Other(&'static str),
1097}
1098
1099pub(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 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
1217fn 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}