1use geop_core_geometry::{
60 contains::curve::curve_could_contain,
61 intersection::{curve_surface_intersect, refine_crossing},
62 nurb_curve::{NurbCurve, NurbCurve2D, NurbCurve3D},
63 nurb_surface::NurbSurface3D,
64 search::{MAX_NODES, min_subdivision_size},
65};
66use geop_core_math::{
67 geop_error::{GeopError, GeopResult, WithContext},
68 plain::{self, P3, add, distance, dot, inverse2, scale, sub},
69 primitives::CoordinateSystem,
70 scalars::Scalar,
71 vector::{Vector2, Vector3},
72 with_context,
73};
74use geop_core_topology::build::BuiltBody;
75use geop_ops_model::{Chain, NamedModel, Namer, Profile};
76
77use crate::{
78 plain::Plain,
79 sweep::{Frame, Path, Span, SweepLoop, skin},
80};
81
82#[derive(Clone, Debug)]
88pub struct Section {
89 pub plane: CoordinateSystem,
90 pub profile: Profile,
91 pub name: String,
92 pub matched: Vec<String>,
93}
94
95#[derive(Clone, Debug)]
97struct Placed {
98 frame: Frame,
99 profile: Profile,
100}
101
102impl Placed {
103 fn joints(&self) -> GeopResult<Vec<[f64; 3]>> {
105 let n = self.profile.joint_names.len();
106 (0..n)
107 .map(|i| {
108 let q = self.frame.point(&self.profile.joint(i)?);
109 Ok(q.to_f64_array())
110 })
111 .collect()
112 }
113
114 fn mirrored(&self) -> Self {
118 let mirror = |c: &NurbCurve2D| {
119 let mut c = c.clone();
120 for cp in &mut c.control_points {
121 cp[1] = cp[1].neg();
122 }
123 c.recompute_aabb();
124 c
125 };
126 Self {
127 frame: Frame {
128 e2: self.frame.e2.neg(),
129 ..self.frame.clone()
130 },
131 profile: self.profile.map_curves(mirror),
132 }
133 }
134}
135
136pub(crate) fn centre(points: &[[f64; 3]]) -> [f64; 3] {
139 let n = points.len() as f64;
140 let mut c = [0.0; 3];
141 for p in points {
142 for k in 0..3 {
143 c[k] += p[k] / n;
144 }
145 }
146 c
147}
148
149fn polygon_length(curve: &NurbCurve2D) -> f64 {
152 let points: Vec<[f64; 2]> = curve
153 .control_points
154 .iter()
155 .map(|cp| {
156 let w = cp[2].to_f64();
157 [cp[0].to_f64() / w, cp[1].to_f64() / w]
158 })
159 .collect();
160 points.windows(2).map(|w| distance(w[1], w[0])).sum()
161}
162
163fn cut(profile: &Profile, i: usize, at: &[f64]) -> GeopResult<Profile> {
172 let curve = &profile.curves[i];
173 let (t0, t1) = curve.domain();
174 let mut bounds = vec![t0];
175 for &f in at {
176 bounds.push(curve.parameter_at_length_fraction(f)?);
177 }
178 bounds.push(t1);
179 let pieces = bounds
180 .windows(2)
181 .map(|w| {
182 curve
183 .sub_curve(w[0], w[1])?
184 .with_unit_domain()?
185 .with_unit_end_weights()
186 })
187 .collect::<GeopResult<Vec<_>>>()?;
188 let mut out = profile.clone();
189 let name = profile.curve_names[i].clone();
190 let count = pieces.len();
191 out.curves.splice(i..=i, pieces);
192 out.curve_names
193 .splice(i + 1..i + 1, (1..count).map(|k| format!("{name}#cut{k}")));
194 out.joint_names
195 .splice(i + 1..i + 1, (1..count).map(|k| format!("{name}@cut{k}")));
196 Ok(out)
197}
198
199pub fn mark(profile: &Profile, point: &Vector2, joint: &str) -> GeopResult<(Profile, String)> {
206 let size = min_subdivision_size();
207 let n = profile.curves.len();
208 for (i, curve) in profile.curves.iter().enumerate() {
209 let (t0, t1) = curve.domain();
210 if curve.evaluate(t0)?.could_be_equal(point) {
211 return Ok((profile.clone(), profile.joint_names[i].clone()));
212 }
213 let Some(t) = curve_could_contain(curve, point, MAX_NODES, size)? else {
214 continue;
215 };
216 if t.could_be_equal(t1) {
217 return Ok((profile.clone(), profile.joint_names[(i + 1) % n].clone()));
218 }
219 let (a, b) = curve.split(t.sharpen())?;
220 let mut out = profile.clone();
221 let name = profile.curve_names[i].clone();
222 out.curves
223 .splice(i..=i, [a.with_unit_domain()?, b.with_unit_domain()?]);
224 out.curve_names.insert(i + 1, format!("{name}#{joint}"));
225 out.joint_names.insert(i + 1, joint.to_string());
226 return Ok((out, joint.to_string()));
227 }
228 Err(GeopError::new(format!(
229 "the point at {point:?} is on none of the profile's curves"
230 )))
231}
232
233fn matched_indices(profile: &Profile, matched: &[String], name: &str) -> GeopResult<Vec<usize>> {
237 let indices = matched
238 .iter()
239 .map(|joint| {
240 profile
241 .joint_names
242 .iter()
243 .position(|j| j == joint)
244 .ok_or_else(|| GeopError::new(format!("loft: no joint {joint} on {name}")))
245 })
246 .collect::<GeopResult<Vec<_>>>()?;
247 if indices.windows(2).any(|w| w[0] >= w[1]) {
248 return Err(GeopError::new(format!(
249 "loft: the matching points of {name} do not run round it in the order they are matched with the other profiles'"
250 )));
251 }
252 Ok(indices)
253}
254
255fn runs(profile: &Profile, indices: &[usize]) -> Vec<std::ops::Range<usize>> {
259 let n = profile.curves.len();
260 (0..indices.len())
261 .map(|j| indices[j]..indices.get(j + 1).copied().unwrap_or(n))
262 .collect()
263}
264
265fn run_fractions(
270 profile: &Profile,
271 run: std::ops::Range<usize>,
272) -> GeopResult<(Vec<Scalar>, Scalar)> {
273 let lengths = run
274 .map(|i| Ok(profile.curves[i].length()?.0))
275 .collect::<GeopResult<Vec<Scalar>>>()?;
276 let total = lengths.iter().fold(Scalar::ZERO, |sum, &l| sum.add(l));
277 let mut fractions = Vec::with_capacity(lengths.len() - 1);
278 let mut sum = Scalar::ZERO;
279 for &l in &lengths[..lengths.len() - 1] {
280 sum = sum.add(l);
281 fractions.push(sum.div(total)?);
282 }
283 Ok((fractions, total))
284}
285
286struct RunCuts {
288 at: Vec<f64>,
291 own: Vec<Vec<usize>>,
293}
294
295impl RunCuts {
296 fn merged(fractions: &[Vec<Scalar>], name: impl Fn(usize) -> String) -> GeopResult<Self> {
305 let mut merged: Vec<Scalar> = Vec::new();
306 let mut own: Vec<Vec<usize>> = vec![Vec::new(); fractions.len()];
307 for (s, fractions) in fractions.iter().enumerate() {
308 for &f in fractions {
309 let claimed = own[s].last().map_or(0, |&k| k + 1);
311 let k = match merged[claimed..].iter().position(|m| m.could_be_equal(f)) {
312 Some(k) => {
313 merged[claimed + k] = merged[claimed + k].union(f);
314 claimed + k
315 }
316 None => {
317 let at = merged
318 .iter()
319 .position(|m| !m.definitely_less(f))
320 .unwrap_or(merged.len());
321 if at < claimed {
322 return Err(GeopError::new(format!(
323 "loft: two joints of the profile {} could lie at one place along it, {:?} of the way along the run between its matched points",
324 name(s),
325 f
326 )));
327 }
328 merged.insert(at, f);
329 for k in own.iter_mut().flatten().filter(|k| **k >= at) {
330 *k += 1;
331 }
332 at
333 }
334 };
335 own[s].push(k);
336 }
337 }
338 let at: Vec<f64> = merged.iter().map(|m| m.to_f64()).collect();
339 if !at.windows(2).all(|w| w[0] < w[1]) || at.iter().any(|&c| !(0.0 < c && c < 1.0)) {
340 return Err(GeopError::new(format!(
341 "loft: the joints along corresponding runs of the profiles cannot be told apart: {merged:?}"
342 )));
343 }
344 Ok(Self { at, own })
345 }
346
347 fn halve(&mut self, k: usize) {
351 let bound = |i: usize| -> f64 {
352 if i == 0 {
353 0.0
354 } else {
355 self.at.get(i - 1).copied().unwrap_or(1.0)
356 }
357 };
358 let middle = (bound(k) + bound(k + 1)) / 2.0;
359 self.at.insert(k, middle);
360 for i in self.own.iter_mut().flatten().filter(|i| **i >= k) {
361 *i += 1;
362 }
363 }
364
365 fn pieces(&self) -> usize {
367 self.at.len() + 1
368 }
369
370 fn stretch(&self, k: usize) -> f64 {
373 let bounds: Vec<f64> = [0.0]
374 .into_iter()
375 .chain(self.at.iter().copied())
376 .chain([1.0])
377 .collect();
378 bounds[k + 1] - bounds[k]
379 }
380}
381
382fn cut_runs(
388 profile: &Profile,
389 runs: &[std::ops::Range<usize>],
390 cuts: &[RunCuts],
391 s: usize,
392) -> GeopResult<Profile> {
393 let mut out = profile.clone();
394 for j in (0..runs.len()).rev() {
397 let run = &runs[j];
398 let mut joints = cuts[j].own[s].iter().peekable();
399 let mut per_curve: Vec<Vec<f64>> = vec![Vec::new(); run.len()];
402 let mut curve = 0;
403 let mut start = 0.0;
404 for (k, &c) in cuts[j].at.iter().enumerate() {
405 if joints.next_if_eq(&&k).is_some() {
406 for f in &mut per_curve[curve] {
408 *f = (*f - start) / (c - start);
409 }
410 curve += 1;
411 start = c;
412 } else {
413 per_curve[curve].push(c);
414 }
415 }
416 for f in &mut per_curve[curve] {
417 *f = (*f - start) / (1.0 - start);
418 }
419 for (k, at) in per_curve.iter().enumerate().rev() {
420 if !at.is_empty() {
421 out = cut(&out, run.start + k, at)?;
422 }
423 }
424 }
425 Ok(out)
426}
427
428fn corner_anchors(
436 sections: &[Section],
437 placed: &[Placed],
438 centres: &[[f64; 3]],
439) -> GeopResult<Vec<Vec<String>>> {
440 let corners = placed
441 .iter()
442 .map(|p| p.profile.corners())
443 .collect::<GeopResult<Vec<Vec<usize>>>>()?;
444 let mut points = vec![Vec::new(); placed.len()];
445 let cornered: Vec<usize> = (0..placed.len())
446 .filter(|&s| !corners[s].is_empty())
447 .collect();
448 let Some(&reference) = cornered.first() else {
449 return Ok(points);
450 };
451 let count = corners[reference].len();
452 if let Some(&s) = cornered.iter().find(|&&s| corners[s].len() != count) {
453 return Err(GeopError::new(format!(
454 "loft: the profile {} has {} corners, the profile {} has {count}: lofted as they are, the profiles' corners are matched one to one, so every profile with corners needs as many — match points, or add guides, to say which go where",
455 sections[s].name,
456 corners[s].len(),
457 sections[reference].name
458 )));
459 }
460 let about = |s: usize, k: usize| -> GeopResult<[f64; 3]> {
461 let joint = placed[s]
462 .frame
463 .point(&placed[s].profile.joint(corners[s][k])?);
464 Ok(sub(joint.to_f64_array(), centres[s]))
465 };
466 let mut shifts = vec![0; placed.len()];
467 for w in cornered.windows(2) {
468 let (previous, s) = (w[0], w[1]);
469 let before = (0..count)
470 .map(|k| about(previous, (k + shifts[previous]) % count))
471 .collect::<GeopResult<Vec<_>>>()?;
472 let mut best: Option<(f64, usize)> = None;
473 for shift in 0..count {
474 let mut cost = 0.0;
475 for (k, &b) in before.iter().enumerate() {
476 let d = sub(about(s, (k + shift) % count)?, b);
477 cost += dot(d, d);
478 }
479 if best.is_none_or(|(c, _)| cost < c) {
480 best = Some((cost, shift));
481 }
482 }
483 shifts[s] = best.expect("a corner to start at").1;
484 }
485 for &s in &cornered {
486 points[s] = (0..count)
487 .map(|k| placed[s].profile.joint_names[corners[s][(k + shifts[s]) % count]].clone())
488 .collect();
489 }
490 Ok(points)
491}
492
493fn collapsed(next: &Placed, point: &Vector3) -> GeopResult<Placed> {
499 let profile = next.profile.map_curves(|c| {
500 let mut c = c.clone();
501 for cp in &mut c.control_points {
502 cp[0] = Scalar::ZERO;
503 cp[1] = Scalar::ZERO;
504 }
505 c.recompute_aabb();
506 c
507 });
508 Ok(Placed {
509 frame: Frame {
510 origin: *point,
511 ..next.frame.clone()
512 },
513 profile,
514 })
515}
516
517fn correspond(sections: &[Section], beyond: [Option<[f64; 3]>; 2]) -> GeopResult<Vec<Placed>> {
523 let closed = sections[0].profile.is_closed();
524 if sections.iter().any(|s| s.profile.is_closed() != closed) {
525 return Err(GeopError::new(
526 "loft: the profiles are either all closed or all open",
527 ));
528 }
529 let mut placed: Vec<Placed> = sections
530 .iter()
531 .map(|s| Placed {
532 frame: Frame {
533 origin: *s.plane.origin(),
534 e1: *s.plane.u(),
535 e2: *s.plane.v(),
536 },
537 profile: s.profile.clone(),
538 })
539 .collect();
540 let centres = placed
541 .iter()
542 .map(|p| Ok(centre(&p.joints()?)))
543 .collect::<GeopResult<Vec<_>>>()?;
544
545 if closed {
548 let n = placed.len();
549 let v = |a: [f64; 3]| Vector3::from_array(a.map(Scalar::from_f64));
550 for s in 0..n {
551 let (from, to) = match (s + 1 < n, s > 0, beyond) {
552 (true, _, _) => (centres[s], centres[s + 1]),
553 (false, _, [_, Some(after)]) => (centres[s], after),
554 (false, true, _) => (centres[s - 1], centres[s]),
555 (false, false, [Some(before), None]) => (before, centres[s]),
556 (false, false, [None, None]) => {
557 return Err(GeopError::new("loft: one profile, and nothing to loft to"));
558 }
559 };
560 let along = v(to).sub(&v(from));
561 let frame = &placed[s].frame;
562 let facing = frame.e1.prod_cross(&frame.e2).prod_dot(&along);
563 if facing.definitely_greater(Scalar::ZERO) {
564 let reversed = placed[s].mirrored();
565 placed[s] = Placed {
566 profile: reversed.profile.reversed(),
567 ..reversed
568 };
569 } else if !facing.definitely_less(Scalar::ZERO) {
570 return Err(GeopError::new(format!(
571 "loft: the loft could run along the plane of {}",
572 sections[s].name
573 )));
574 }
575 }
576 }
577
578 let mut points: Vec<Vec<String>> = sections.iter().map(|s| s.matched.clone()).collect();
583 let explicit = points.iter().any(|p| !p.is_empty());
584 if let Some(reference) = (0..sections.len()).find(|&s| !points[s].is_empty()) {
585 let joints = &placed[reference].profile.joint_names;
586 let mut order: Vec<usize> = (0..points[reference].len()).collect();
587 order.sort_by_key(|&k| joints.iter().position(|j| *j == points[reference][k]));
588 for list in points.iter_mut().filter(|l| l.len() == order.len()) {
589 *list = order.iter().map(|&k| list[k].clone()).collect();
590 }
591 }
592 if !explicit && closed {
593 points = corner_anchors(sections, &placed, ¢res)?;
594 }
595
596 let matched: Vec<usize> = (0..sections.len())
599 .filter(|&s| !points[s].is_empty())
600 .collect();
601 let mut most = placed
602 .iter()
603 .map(|p| p.profile.curves.len())
604 .max()
605 .unwrap_or(0);
606 if let Some(&first) = matched.first() {
607 if !closed {
608 return Err(GeopError::new(
609 "loft: matching points are for closed profiles: open chains are lofted end to end",
610 ));
611 }
612 let count = points[first].len();
613 if let Some(&s) = matched.iter().find(|&&s| points[s].len() != count) {
614 return Err(GeopError::new(format!(
615 "loft: {} has {} matching points, {} has {}: every profile matched has as many",
616 sections[s].name,
617 points[s].len(),
618 sections[first].name,
619 count
620 )));
621 }
622 for &s in &matched {
623 let start = placed[s]
624 .profile
625 .joint_names
626 .iter()
627 .position(|j| *j == points[s][0])
628 .ok_or_else(|| GeopError::new("loft: the first matching point is no joint"))?;
629 placed[s].profile = placed[s].profile.starting_at(start);
630 }
631 let indices = |points: &[Vec<String>]| -> GeopResult<Vec<Vec<usize>>> {
632 matched
633 .iter()
634 .map(|&s| matched_indices(&placed[s].profile, &points[s], §ions[s].name))
635 .collect()
636 };
637
638 if explicit {
642 let inner: Vec<Vec<Vec<String>>> = matched
643 .iter()
644 .zip(indices(&points)?)
645 .map(|(&s, indices)| {
646 let profile = &placed[s].profile;
647 let corners = profile.corners()?;
648 Ok(runs(profile, &indices)
649 .into_iter()
650 .map(|run| {
651 corners
652 .iter()
653 .filter(|&&c| run.start < c && c < run.end)
654 .map(|&c| profile.joint_names[c].clone())
655 .collect()
656 })
657 .collect())
658 })
659 .collect::<GeopResult<_>>()?;
660 for (m, &s) in matched.iter().enumerate() {
661 let mut all = Vec::new();
662 for j in 0..count {
663 all.push(points[s][j].clone());
664 if inner.iter().all(|i| i[j].len() == inner[m][j].len()) {
665 all.extend(inner[m][j].iter().cloned());
666 }
667 }
668 points[s] = all;
669 }
670 }
671
672 let run_list: Vec<Vec<std::ops::Range<usize>>> = matched
676 .iter()
677 .zip(indices(&points)?)
678 .map(|(&s, indices)| runs(&placed[s].profile, &indices))
679 .collect();
680 let fractions: Vec<Vec<(Vec<Scalar>, Scalar)>> = matched
681 .iter()
682 .zip(&run_list)
683 .map(|(&s, runs)| {
684 runs.iter()
685 .map(|run| run_fractions(&placed[s].profile, run.clone()))
686 .collect()
687 })
688 .collect::<GeopResult<_>>()?;
689 let count = run_list[0].len();
690 let mut cuts: Vec<RunCuts> = (0..count)
691 .map(|j| {
692 let along: Vec<Vec<Scalar>> = fractions.iter().map(|f| f[j].0.clone()).collect();
693 RunCuts::merged(&along, |m| sections[matched[m]].name.clone())
694 })
695 .collect::<GeopResult<_>>()?;
696 let total = |cuts: &[RunCuts]| cuts.iter().map(RunCuts::pieces).sum::<usize>();
701 while total(&cuts) < most {
702 let mut longest: Option<(f64, usize, usize)> = None;
703 for (j, run) in cuts.iter().enumerate() {
704 let length = fractions[0][j].1.to_f64();
705 for k in 0..run.pieces() {
706 let stretch = run.stretch(k) * length;
707 if longest.is_none_or(|(l, _, _)| stretch > l) {
708 longest = Some((stretch, j, k));
709 }
710 }
711 }
712 let (_, j, k) = longest.expect("a run has a stretch");
713 cuts[j].halve(k);
714 }
715 most = total(&cuts);
716 for (m, &s) in matched.iter().enumerate() {
717 placed[s].profile = cut_runs(&placed[s].profile, &run_list[m], &cuts, m)
718 .with_context(with_context!("cutting the profile {}", sections[s].name))?;
719 }
720 }
721
722 for (s, p) in placed.iter_mut().enumerate() {
725 if points[s].is_empty() {
726 for _ in p.profile.curves.len()..most {
727 let longest = (0..p.profile.curves.len())
728 .max_by(|&a, &b| {
729 polygon_length(&p.profile.curves[a])
730 .total_cmp(&polygon_length(&p.profile.curves[b]))
731 })
732 .expect("a profile has curves");
733 p.profile = cut(&p.profile, longest, &[0.5])?;
734 }
735 }
736 }
737
738 let reference = matched.first().copied().unwrap_or(0);
741 let order: Vec<(usize, usize)> = (reference + 1..placed.len())
742 .map(|s| (s, s - 1))
743 .chain((0..reference).rev().map(|s| (s, s + 1)))
744 .collect();
745 for (s, neighbour) in order {
746 if !points[s].is_empty() {
747 continue;
748 }
749 let before = placed[neighbour].joints()?;
750 let candidates: Vec<Profile> = if closed {
751 (0..most)
752 .map(|k| placed[s].profile.starting_at(k))
753 .collect()
754 } else {
755 vec![placed[s].profile.clone(), placed[s].profile.reversed()]
756 };
757 let mut best: Option<(f64, Profile)> = None;
758 for candidate in candidates {
759 let joints = Placed {
760 frame: placed[s].frame.clone(),
761 profile: candidate.clone(),
762 }
763 .joints()?;
764 let (ca, cb) = if closed {
766 (centres[s], centres[neighbour])
767 } else {
768 ([0.0; 3], [0.0; 3])
769 };
770 let cost: f64 = joints
771 .iter()
772 .zip(&before)
773 .map(|(&a, &b)| {
774 let d = sub(sub(a, ca), sub(b, cb));
775 dot(d, d)
776 })
777 .sum();
778 if best.as_ref().is_none_or(|(c, _)| cost < *c) {
779 best = Some((cost, candidate));
780 }
781 }
782 placed[s].profile = best.expect("at least one candidate").1;
783 }
784
785 for i in 0..most {
787 let curves: Vec<NurbCurve2D> = placed
788 .iter()
789 .map(|p| p.profile.curves[i].with_unit_end_weights())
790 .collect::<GeopResult<_>>()?;
791 for (p, curve) in placed.iter_mut().zip(NurbCurve::compatible(&curves)?) {
792 p.profile.curves[i] = curve;
793 }
794 }
795 Ok(placed)
796}
797
798struct Crossed {
803 chain: Chain<NurbCurve3D>,
804 at: Vec<(usize, Scalar)>,
805}
806
807fn crossings(sections: &[Section], guide: &Chain<NurbCurve3D>) -> GeopResult<Crossed> {
812 let name = &guide.name;
813 if guide.is_closed() {
814 return Err(GeopError::new(format!(
815 "loft: the guide {name} is a closed loop: a guide runs from the first profile to the last"
816 )));
817 }
818 let (first, last) = (§ions[0], §ions[sections.len() - 1]);
819 let mut chain = guide.starting_on(&first.plane)?;
820 if let Some(c) = &chain
821 && !last.plane.to_uvw(&c.joint(c.curves.len())?)[2].could_be_equal(Scalar::ZERO)
822 {
823 chain = None;
824 }
825 let Some(chain) = chain else {
826 return Err(GeopError::new(format!(
827 "loft: the guide {name} has to run from the profile {} to the profile {}, starting and ending on them",
828 first.name, last.name
829 )));
830 };
831 let n = chain.curves.len();
832 let mut at = vec![(0, Scalar::ZERO)];
833 for section in §ions[1..sections.len() - 1] {
834 let ctx = with_context!(
835 "where the guide {name} crosses the profile {}",
836 section.name
837 );
838 let patch = plane_patch(§ion.plane, &chain)?;
839 let mut hits = Vec::new();
840 for (i, curve) in chain.curves.iter().enumerate() {
841 let found =
842 curve_surface_intersect(curve, &patch, 4, MAX_NODES, min_subdivision_size())
843 .with_context(ctx)?;
844 if found.is_coincident() {
845 return Err(GeopError::new(format!(
846 "loft: the guide {name} runs along the plane of the profile {}",
847 section.name
848 )));
849 }
850 for (t, uv) in found.into_vec() {
851 if i > 0 && t.could_be_equal(Scalar::ZERO) {
854 continue;
855 }
856 let (t, _) = refine_crossing(curve, &patch, t, uv);
857 hits.push((i, t));
858 }
859 }
860 match hits.as_slice() {
861 [hit] => at.push(*hit),
862 _ => {
863 return Err(GeopError::new(format!(
864 "loft: the guide {name} crosses the plane of the profile {} {} times, not once",
865 section.name,
866 hits.len()
867 )));
868 }
869 }
870 }
871 at.push((n - 1, Scalar::ONE));
872 Ok(Crossed { chain, at })
873}
874
875fn plane_patch(plane: &CoordinateSystem, chain: &Chain<NurbCurve3D>) -> GeopResult<NurbSurface3D> {
879 let uvs = chain
880 .curves
881 .iter()
882 .flat_map(|c| &c.control_points)
883 .map(|cp| Ok(plane.to_uv(&cp.dehomogenized()?).to_f64_array()))
884 .collect::<GeopResult<Vec<_>>>()?;
885 let (lo, hi) =
886 plain::bounds(uvs).ok_or_else(|| GeopError::new("the path has no control points"))?;
887 let margin = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(1.0);
888 let corner = |u: f64, v: f64| {
889 plane.uv_to_xyz(&Vector2::from_array([
890 Scalar::from_f64(u),
891 Scalar::from_f64(v),
892 ]))
893 };
894 let (u0, u1, v0, v1) = (
895 lo[0] - margin,
896 hi[0] + margin,
897 lo[1] - margin,
898 hi[1] + margin,
899 );
900 NurbSurface3D::bilinear(
901 corner(u0, v0),
902 corner(u1, v0),
903 corner(u1, v1),
904 corner(u0, v1),
905 )
906}
907
908type Guided = (Vec<Section>, Vec<Crossed>);
910
911fn guided(sections: &[Section], guides: &[Chain<NurbCurve3D>]) -> GeopResult<Guided> {
916 if guides.len() > MAX_GUIDES {
917 return Err(GeopError::new(format!(
918 "loft: {} guides given, but a loft follows at most {MAX_GUIDES}",
919 guides.len()
920 )));
921 }
922 if let Some(s) = sections.iter().find(|s| !s.matched.is_empty()) {
923 return Err(GeopError::new(format!(
924 "loft: the profile {} has matching points, but the guides say which points match: use one or the other",
925 s.name
926 )));
927 }
928 let crossed = guides
929 .iter()
930 .map(|g| crossings(sections, g))
931 .collect::<GeopResult<Vec<_>>>()?;
932 let mut out = sections.to_vec();
933 for (j, section) in out.iter_mut().enumerate() {
934 for (guide, crossed) in guides.iter().zip(&crossed) {
935 let (i, t) = crossed.at[j];
936 let point = crossed.chain.curves[i].evaluate(t)?;
937 let (profile, joint) = mark(
938 §ion.profile,
939 §ion.plane.to_uv(&point),
940 &format!("{},{}", section.name, guide.name),
941 )
942 .with_context(with_context!(
943 "the guide {} on the profile {}",
944 guide.name,
945 section.name
946 ))?;
947 section.profile = profile;
948 section.matched.push(joint);
949 }
950 }
951 if guides.len() == 3 {
954 let joint = |name: &String| -> GeopResult<Vector2> {
955 let profile = &out[0].profile;
956 let i = profile
957 .joint_names
958 .iter()
959 .position(|j| j == name)
960 .expect("a guide's joint is on its profile");
961 profile.joint(i)
962 };
963 let q = out[0]
964 .matched
965 .iter()
966 .map(joint)
967 .collect::<GeopResult<Vec<_>>>()?;
968 let (a, b) = (q[1].sub(&q[0]), q[2].sub(&q[0]));
969 if a[0]
970 .mul(b[1])
971 .sub(a[1].mul(b[0]))
972 .could_be_equal(Scalar::ZERO)
973 {
974 return Err(GeopError::new(format!(
975 "loft: the guides {} could meet the profile {} in a line: they cannot shape it across that line",
976 guides
977 .iter()
978 .map(|g| g.name.as_str())
979 .collect::<Vec<_>>()
980 .join(", "),
981 out[0].name
982 )));
983 }
984 }
985 Ok((out, crossed))
986}
987
988const MAX_GUIDES: usize = 3;
991
992fn guide_piece(crossed: &Crossed, j: usize) -> GeopResult<NurbCurve3D> {
997 let ((c0, t0), (c1, t1)) = (crossed.at[j], crossed.at[j + 1]);
998 let (t0, t1) = (t0.sharpen(), t1.sharpen());
999 let mut curves = Vec::new();
1000 for i in c0..=c1 {
1001 let curve = &crossed.chain.curves[i];
1002 let from = if i == c0 { t0 } else { Scalar::ZERO };
1003 let to = if i == c1 { t1 } else { Scalar::ONE };
1004 if from.could_be_equal(to) {
1005 continue;
1006 }
1007 curves.push(if i == c0 || i == c1 {
1008 curve.sub_curve(from, to)?
1009 } else {
1010 curve.clone()
1011 });
1012 }
1013 NurbCurve::join(&curves)
1014}
1015
1016fn guided_span(
1031 a: &Placed,
1032 b: &Placed,
1033 joints: &[(String, String)],
1034 pieces: &[NurbCurve3D],
1035) -> GeopResult<Span> {
1036 let pieces = NurbCurve::compatible(pieces)?;
1037 let first = &pieces[0];
1038 let (degree, knots) = (first.degree, first.knot_vector.clone());
1039 let rows = first.control_points.len();
1040 for (k, piece) in pieces.iter().enumerate().skip(1) {
1041 if !(0..rows).all(|r| piece.control_points[r][3].could_be_equal(first.control_points[r][3]))
1042 {
1043 return Err(GeopError::new(format!(
1044 "loft: the guides through {} and {} are rational curves of different weights: draw them alike, or as splines",
1045 joints[0].0, joints[k].0
1046 )));
1047 }
1048 }
1049 let joint_at = |placed: &Placed, name: &str| -> GeopResult<[f64; 2]> {
1050 let profile = &placed.profile;
1051 let i = profile
1052 .joint_names
1053 .iter()
1054 .position(|j| j == name)
1055 .ok_or_else(|| GeopError::new(format!("loft: no joint {name} for a guide")))?;
1056 let p = profile.joint(i)?;
1057 Ok([p[0].to_f64(), p[1].to_f64()])
1058 };
1059 let ends: Vec<([f64; 2], [f64; 2])> = joints
1060 .iter()
1061 .map(|(ja, jb)| Ok((joint_at(a, ja)?, joint_at(b, jb)?)))
1062 .collect::<GeopResult<_>>()?;
1063 let (fa, fb) = (Plain::of(&a.frame), Plain::of(&b.frame));
1064 let place = |f: &Plain, p: [f64; 2]| add(f.origin, add(scale(f.e1, p[0]), scale(f.e2, p[1])));
1065 let mut middle = Vec::with_capacity(rows.saturating_sub(2));
1066 for r in 1..rows - 1 {
1067 let xi = knots[r + 1..=r + degree]
1068 .iter()
1069 .map(|k| k.to_f64())
1070 .sum::<f64>()
1071 / degree as f64;
1072 let weight = first.control_points[r][3];
1073 let (q, d): (Vec<[f64; 2]>, Vec<P3>) = ends
1076 .iter()
1077 .zip(&pieces)
1078 .map(|(&(ja, jb), piece)| {
1079 let q = [
1080 (1.0 - xi) * ja[0] + xi * jb[0],
1081 (1.0 - xi) * ja[1] + xi * jb[1],
1082 ];
1083 let ruled = add(scale(place(&fa, ja), 1.0 - xi), scale(place(&fb, jb), xi));
1084 let cp = piece.control_points[r];
1085 let w = cp[3].to_f64();
1086 let g = [cp[0].to_f64() / w, cp[1].to_f64() / w, cp[2].to_f64() / w];
1087 (q, sub(g, ruled))
1088 })
1089 .unzip();
1090 let (t, m) = affine_through(&q, &d);
1091 let moved = |f: &Plain| Plain {
1092 origin: add(f.origin, t),
1093 e1: add(f.e1, m[0]),
1094 e2: add(f.e2, m[1]),
1095 };
1096 middle.push([
1097 (moved(&fa).frame(), weight.mul(Scalar::from_f64(1.0 - xi))),
1098 (moved(&fb).frame(), weight.mul(Scalar::from_f64(xi))),
1099 ]);
1100 }
1101 Ok(Span::Blend {
1102 degree,
1103 knots,
1104 middle,
1105 })
1106}
1107
1108fn affine_through(q: &[[f64; 2]], d: &[P3]) -> (P3, [P3; 2]) {
1112 let m = match q.len() {
1113 1 => [[0.0; 3]; 2],
1114 2 => {
1115 let (dq, dd) = (sub(q[1], q[0]), sub(d[1], d[0]));
1116 let n = dot(dq, dq);
1117 [scale(dd, dq[0] / n), scale(dd, dq[1] / n)]
1118 }
1119 _ => {
1120 let (q1, q2) = (sub(q[1], q[0]), sub(q[2], q[0]));
1121 let (d1, d2) = (sub(d[1], d[0]), sub(d[2], d[0]));
1122 let inv = inverse2([[q1[0], q2[0]], [q1[1], q2[1]]]);
1124 [
1125 add(scale(d1, inv[0][0]), scale(d2, inv[1][0])),
1126 add(scale(d1, inv[0][1]), scale(d2, inv[1][1])),
1127 ]
1128 }
1129 };
1130 let t = sub(d[0], add(scale(m[0], q[0][0]), scale(m[1], q[0][1])));
1131 (t, m)
1132}
1133
1134fn through(placed: &[Placed], closed: bool) -> GeopResult<Vec<Span>> {
1147 let n = if closed {
1149 placed.len()
1150 } else {
1151 placed.len() - 1
1152 };
1153 let station = |s: usize| s % placed.len();
1154 let centres = placed
1155 .iter()
1156 .map(|p| Ok(centre(&p.joints()?)))
1157 .collect::<GeopResult<Vec<_>>>()?;
1158 let mut d: Vec<f64> = (0..n)
1159 .map(|j| distance(centres[j], centres[station(j + 1)]))
1160 .collect();
1161 if d.iter().any(|&d| d <= 0.0) {
1162 d = vec![1.0; n];
1163 }
1164 let unit = |s: usize| -> Vec<f64> {
1166 (0..placed.len())
1167 .map(|k| if k == station(s) { 1.0 } else { 0.0 })
1168 .collect()
1169 };
1170 let combine = |a: &[f64], x: f64, b: &[f64], y: f64| -> Vec<f64> {
1171 a.iter().zip(b).map(|(a, b)| x * a + y * b).collect()
1172 };
1173 let chord = |j: usize| combine(&unit(j + 1), 1.0 / d[j], &unit(j), -1.0 / d[j]);
1175 let mut tangent: Vec<Vec<f64>> = vec![Vec::new(); n + 1];
1176 for j in 1..n {
1177 let w = d[j - 1] + d[j];
1178 tangent[j] = combine(&chord(j - 1), d[j] / w, &chord(j), d[j - 1] / w);
1179 }
1180 if closed {
1181 let w = d[n - 1] + d[0];
1184 tangent[0] = combine(&chord(n - 1), d[0] / w, &chord(0), d[n - 1] / w);
1185 tangent[n] = tangent[0].clone();
1186 } else {
1187 tangent[0] = combine(&chord(0), 2.0, &tangent[1], -1.0);
1188 tangent[n] = combine(&chord(n - 1), 2.0, &tangent[n - 1], -1.0);
1189 }
1190 let row = |coefficients: Vec<f64>| -> Vec<(usize, Scalar)> {
1191 coefficients
1192 .into_iter()
1193 .enumerate()
1194 .filter(|&(_, c)| c != 0.0)
1195 .map(|(s, c)| (s, Scalar::from_f64(c)))
1196 .collect()
1197 };
1198 let (zero, one) = (Scalar::ZERO, Scalar::ONE);
1199 Ok((0..n)
1200 .map(|j| Span::Through {
1201 degree: 3,
1202 knots: vec![zero, zero, zero, zero, one, one, one, one],
1203 middle: vec![
1204 row(combine(&unit(j), 1.0, &tangent[j], d[j] / 3.0)),
1205 row(combine(&unit(j + 1), 1.0, &tangent[j + 1], -d[j] / 3.0)),
1206 ],
1207 })
1208 .collect())
1209}
1210
1211#[derive(Clone, Debug)]
1215pub struct Apex {
1216 pub point: Vector3,
1217 pub name: String,
1218}
1219
1220#[derive(Clone, Debug)]
1224pub enum Ends {
1225 Open([Option<Apex>; 2]),
1226 Closed,
1227}
1228
1229impl Ends {
1230 pub const CAPPED: Ends = Ends::Open([None, None]);
1232}
1233
1234pub fn loft(
1251 model: &mut NamedModel,
1252 namer: &Namer,
1253 solid: Option<&str>,
1254 sections: &[Section],
1255 ends: &Ends,
1256 guides: &[Chain<NurbCurve3D>],
1257) -> GeopResult<BuiltBody> {
1258 let (apexes, closed) = match ends {
1259 Ends::Open(apexes) => (apexes, false),
1260 Ends::Closed => (&[None, None], true),
1261 };
1262 let ctx = with_context!(
1263 "loft({}, through {:?}, apexes {:?})",
1264 namer.root(),
1265 sections.iter().map(|s| &s.name).collect::<Vec<_>>(),
1266 apexes.iter().flatten().map(|a| &a.name).collect::<Vec<_>>()
1267 );
1268 let ends_count = apexes.iter().flatten().count();
1269 let ends = ends_count;
1270 if sections.is_empty() || sections.len() + ends < 2 {
1271 return Err(GeopError::new(format!(
1272 "loft: needs two profiles or more, or a profile and a point, not {} profile(s) and {ends} point(s)",
1273 sections.len()
1274 )))
1275 .with_context(ctx);
1276 }
1277 if ends > 0 && !guides.is_empty() {
1278 return Err(GeopError::new(
1279 "loft: guides run from the first profile to the last, and a loft to a point has a point at an end: loft to the point without guides",
1280 ))
1281 .with_context(ctx);
1282 }
1283 if closed && (sections.len() < 3 || !guides.is_empty()) {
1284 return Err(GeopError::new(format!(
1285 "loft: a loft closing on itself runs through three profiles or more, without guides, not {} profile(s) and {} guide(s)",
1286 sections.len(),
1287 guides.len()
1288 )))
1289 .with_context(ctx);
1290 }
1291 if ends > 0 && !sections[0].profile.is_closed() {
1292 return Err(GeopError::new(
1293 "loft: only a closed profile comes to a point",
1294 ))
1295 .with_context(ctx);
1296 }
1297 let (sections, crossed) = if guides.is_empty() {
1298 (sections.to_vec(), Vec::new())
1299 } else {
1300 guided(sections, guides).with_context(ctx)?
1301 };
1302 let beyond = [0, 1].map(|k| apexes[k].as_ref().map(|a| a.point.to_f64_array()));
1303 let mut placed = correspond(§ions, beyond).with_context(ctx)?;
1304 let mut names: Vec<String> = sections.iter().map(|s| s.name.clone()).collect();
1305 let mut apex = vec![false; sections.len()];
1306 for (k, end) in apexes.iter().enumerate() {
1307 let Some(end) = end else { continue };
1308 let next = if k == 0 { 0 } else { placed.len() - 1 };
1309 let point = collapsed(&placed[next], &end.point).with_context(ctx)?;
1310 if k == 0 {
1311 placed.insert(0, point);
1312 names.insert(0, end.name.clone());
1313 apex.insert(0, true);
1314 } else {
1315 placed.push(point);
1316 names.push(end.name.clone());
1317 apex.push(true);
1318 }
1319 }
1320 let spans = if closed {
1321 placed.len()
1322 } else {
1323 placed.len() - 1
1324 };
1325 let through = if crossed.is_empty() && spans > 1 {
1326 Some(through(&placed, closed).with_context(ctx)?)
1327 } else {
1328 None
1329 };
1330 let span_list = (0..spans)
1331 .map(|j| {
1332 if let Some(through) = &through {
1333 return Ok(through[j].clone());
1334 }
1335 if crossed.is_empty() {
1336 return Ok(Span::Line);
1337 }
1338 let joints: Vec<(String, String)> = (0..guides.len())
1339 .map(|k| {
1340 (
1341 sections[j].matched[k].clone(),
1342 sections[j + 1].matched[k].clone(),
1343 )
1344 })
1345 .collect();
1346 let pieces = crossed
1347 .iter()
1348 .map(|c| guide_piece(c, j))
1349 .collect::<GeopResult<Vec<_>>>()?;
1350 guided_span(&placed[j], &placed[j + 1], &joints, &pieces).with_context(with_context!(
1351 "between the profiles {} and {}",
1352 sections[j].name,
1353 sections[j + 1].name
1354 ))
1355 })
1356 .collect::<GeopResult<Vec<_>>>()
1357 .with_context(ctx)?;
1358 let path = Path {
1359 stations: placed.iter().map(|p| p.frame.clone()).collect(),
1360 spans: span_list,
1361 closed,
1362 along_normal: false,
1363 span_names: if spans == 1 {
1364 vec![None]
1365 } else {
1366 (0..spans)
1367 .map(|j| Some(format!("{}>{}", names[j], names[(j + 1) % names.len()])))
1368 .collect()
1369 },
1370 station_names: names,
1371 };
1372 let loops: Vec<Vec<SweepLoop>> = placed
1373 .into_iter()
1374 .zip(apex)
1375 .map(|(p, apex)| {
1376 vec![SweepLoop {
1377 apex,
1378 ..SweepLoop::plain(p.profile)
1379 }]
1380 })
1381 .collect();
1382 skin(model, namer, &path, &loops, solid).with_context(ctx)
1383}
1384
1385#[cfg(test)]
1386mod tests {
1387 use super::*;
1388 use crate::common::polygon;
1389 use crate::shapes::{circle, plane, square, v2, v3};
1390 use geop_core_geometry::nurb_curve::{NurbCurve2D, sqrt2_over_2};
1391 use geop_ops_model::testing::{assert_model_valid, assert_model_valid_sheets};
1392
1393 fn level(height: f64) -> CoordinateSystem {
1395 plane(v3(0., 0., height), v3(1., 0., 0.), v3(0., 1., 0.))
1396 }
1397
1398 fn regular(n: usize, r: f64, phase: f64) -> Vec<NurbCurve2D> {
1401 let corners: Vec<Vector2> = (0..n)
1402 .map(|i| {
1403 let a = std::f64::consts::TAU * (phase + i as f64 / n as f64);
1404 v2(r * a.cos(), r * a.sin())
1405 })
1406 .collect();
1407 polygon(&corners).unwrap()
1408 }
1409
1410 fn section(name: &str, plane: CoordinateSystem, curves: Vec<NurbCurve2D>) -> Section {
1411 Section {
1412 plane,
1413 profile: Profile::closed(curves).with_prefix(&format!("{name},")),
1414 name: name.into(),
1415 matched: Vec::new(),
1416 }
1417 }
1418
1419 fn lofted(sections: &[Section]) -> NamedModel {
1421 let mut model = NamedModel::new();
1422 let namer = Namer::new("loft", "l").unwrap();
1423 loft(
1424 &mut model,
1425 &namer,
1426 Some(&namer.root()),
1427 sections,
1428 &Ends::CAPPED,
1429 &[],
1430 )
1431 .unwrap();
1432 model.check_names().unwrap();
1433 assert_model_valid(model.topology());
1434 model
1435 }
1436
1437 #[test]
1439 fn loft_square_to_circle() {
1440 let model = lofted(&[
1441 section("a", level(0.0), regular(4, 1.0, 0.125)),
1442 section("b", level(2.0), circle(0.5)),
1443 ]);
1444 let topology = model.topology();
1445 assert_eq!(topology.faces.len(), 4 + 2);
1446 assert_eq!(topology.vertices.len(), 8);
1447 for v in topology.vertices.values() {
1449 if v.point[2].could_be_equal(Scalar::from_f64(2.0)) {
1450 let (x, y) = (v.point[0], v.point[1]);
1451 assert!(
1452 x.mul(x)
1453 .add(y.mul(y))
1454 .could_be_equal(Scalar::from_f64(0.25))
1455 );
1456 }
1457 }
1458 }
1459
1460 fn lofted_to(sections: &[Section], apex: Vector3, at_end: bool) -> NamedModel {
1463 let mut model = NamedModel::new();
1464 let namer = Namer::new("loft", "l").unwrap();
1465 let apex = Apex {
1466 point: apex,
1467 name: "tip".into(),
1468 };
1469 let ends = if at_end {
1470 Ends::Open([None, Some(apex)])
1471 } else {
1472 Ends::Open([Some(apex), None])
1473 };
1474 loft(
1475 &mut model,
1476 &namer,
1477 Some(&namer.root()),
1478 sections,
1479 &ends,
1480 &[],
1481 )
1482 .unwrap();
1483 model.check_names().unwrap();
1484 assert_model_valid(model.topology());
1485 model
1486 }
1487
1488 #[test]
1491 fn closed_loft_through_four_circles_is_a_ring() {
1492 let sections: Vec<Section> = (0..4)
1493 .map(|k| {
1494 let a = std::f64::consts::FRAC_PI_2 * k as f64;
1495 let (c, s) = (a.cos(), a.sin());
1496 let at = plane(v3(2.0 * c, 2.0 * s, 0.0), v3(c, s, 0.0), v3(0.0, 0.0, 1.0));
1497 section(&format!("s{k}"), at, circle(0.3))
1498 })
1499 .collect();
1500 let mut model = NamedModel::new();
1501 let namer = Namer::new("loft", "l").unwrap();
1502 loft(
1503 &mut model,
1504 &namer,
1505 Some(&namer.root()),
1506 §ions,
1507 &Ends::Closed,
1508 &[],
1509 )
1510 .unwrap();
1511 model.check_names().unwrap();
1512 assert_model_valid(model.topology());
1513 let topology = model.topology();
1514 assert_eq!(topology.faces.len(), 4 * 4);
1515 assert!(model.face_id("loft(l,start)").is_err());
1516 assert!(model.face_id("loft(l,s0,c0,s3>s0)").is_ok());
1517 let surface = |name: &str| {
1520 let face = model.face_id(name).unwrap();
1521 topology.get_face(face).unwrap().surface.clone()
1522 };
1523 let (before, after) = (
1524 surface("loft(l,s0,c0,s3>s0)"),
1525 surface("loft(l,s0,c0,s0>s1)"),
1526 );
1527 let half = Scalar::from_f64(0.5);
1528 let du = |s: &NurbSurface3D, u: f64| {
1529 let (du, _) = s.derivatives(Scalar::from_f64(u), half).unwrap();
1530 du.to_f64_array()
1531 };
1532 let (a, b) = (du(&before, 1.0), du(&after, 0.0));
1533 let unit = |v: [f64; 3]| plain::scale(v, 1.0 / plain::dot(v, v).sqrt());
1534 let (a, b) = (unit(a), unit(b));
1535 assert!(plain::distance(a, b) < 1e-9, "{a:?} {b:?}");
1536 }
1537
1538 fn volume(model: &NamedModel) -> f64 {
1540 let solid = model.solid_id("loft(l)").unwrap();
1541 let mass = model.topology().mass_properties(solid).unwrap();
1542 mass.volume.to_f64()
1543 }
1544
1545 #[test]
1548 fn loft_square_to_a_point_is_a_pyramid() {
1549 let model = lofted_to(
1550 &[section("a", level(0.0), square(1.0))],
1551 v3(0.0, 0.0, 3.0),
1552 true,
1553 );
1554 let topology = model.topology();
1555 assert_eq!(topology.faces.len(), 4 + 1);
1556 assert_eq!(topology.vertices.len(), 5);
1557 assert!(model.vertex_id("loft(l,tip)").is_ok());
1558 assert!((volume(&model) - 4.0).abs() < 1e-9, "{}", volume(&model));
1559 }
1560
1561 #[test]
1564 fn loft_from_a_point_to_a_circle_is_a_cone() {
1565 let model = lofted_to(
1566 &[section("a", level(2.0), circle(1.0))],
1567 v3(0.5, 0.0, 0.0),
1568 false,
1569 );
1570 let expected = std::f64::consts::PI * 2.0 / 3.0;
1571 assert!(
1572 (volume(&model) - expected).abs() < 1e-9,
1573 "{}",
1574 volume(&model)
1575 );
1576 }
1577
1578 #[test]
1582 fn loft_through_two_sections_into_a_point() {
1583 let model = lofted_to(
1584 &[
1585 section("a", level(0.0), square(1.0)),
1586 section("b", level(1.0), circle(0.8)),
1587 ],
1588 v3(0.0, 0.0, 2.0),
1589 true,
1590 );
1591 let topology = model.topology();
1592 assert_eq!(topology.faces.len(), 2 * 4 + 1);
1593 }
1594
1595 #[test]
1597 fn loft_to_a_tilted_plane_facing_the_other_way() {
1598 let tilted = plane(v3(0.2, 0., 2.), v3(0.8, 0., 0.6), v3(0., -1., 0.));
1599 lofted(&[
1600 section("a", level(0.0), regular(4, 1.0, 0.0)),
1601 section("b", tilted, circle(0.5)),
1602 ]);
1603 }
1604
1605 #[test]
1608 fn loft_through_three_sections() {
1609 let model = lofted(&[
1610 section("a", level(0.0), regular(4, 1.0, 0.125)),
1611 section("b", level(1.0), circle(0.5)),
1612 section("c", level(2.5), regular(4, 0.8, 0.0)),
1613 ]);
1614 assert_eq!(model.topology().faces.len(), 2 * 4 + 2);
1615 assert!(model.face_id("loft(l,a,c0,a>b)").is_ok());
1616 assert!(model.face_id("loft(l,a,c0,b>c)").is_ok());
1617 assert!(model.edge_id("loft(l,a,c0,c)").is_ok());
1618 }
1619
1620 #[test]
1624 fn loft_through_three_sections_is_smooth() {
1625 let model = lofted(&[
1626 section("a", level(0.0), circle(1.0)),
1627 section("b", level(1.0), circle(1.5)),
1628 section("c", level(3.0), circle(0.8)),
1629 ]);
1630 let topology = model.topology();
1631 let surface = |name: &str| &topology.faces[&model.face_id(name).unwrap()].surface;
1632 let (below, above) = (surface("loft(l,a,c0,a>b)"), surface("loft(l,a,c0,b>c)"));
1633 for v in [0.1, 0.5, 0.9] {
1634 let normal = |surface: &geop_core_geometry::nurb_surface::NurbSurface3D, u: Scalar| {
1635 let (v0, v1) = surface.domain_v();
1636 let v = Scalar::from_f64(v0.to_f64() + (v1.to_f64() - v0.to_f64()) * v);
1637 surface.normal(u, v).unwrap()
1638 };
1639 let (below, above) = (
1640 normal(below, below.domain_u().1),
1641 normal(above, above.domain_u().0),
1642 );
1643 assert!(
1644 below.could_be_equal(&above),
1645 "the walls meet the middle circle with the normals {below:?} below and {above:?} above"
1646 );
1647 }
1648 }
1649
1650 #[test]
1653 fn loft_triangle_to_circle() {
1654 let model = lofted(&[
1655 section("a", level(0.0), regular(3, 1.0, 0.0)),
1656 section("b", level(1.5), circle(0.6)),
1657 ]);
1658 assert_eq!(model.topology().faces.len(), 4 + 2);
1659 assert!((0..3).any(|k| model.vertex_id(&format!("loft(l,a,c{k}@cut1,b)")).is_ok()));
1661 }
1662
1663 #[test]
1666 fn loft_hexagon_to_circle() {
1667 let model = lofted(&[
1668 section("a", level(0.0), regular(6, 1.0, 0.0)),
1669 section("b", level(1.5), circle(0.6)),
1670 ]);
1671 assert_eq!(model.topology().faces.len(), 6 + 2);
1672 }
1673
1674 #[test]
1676 fn loft_between_two_curves_is_a_ruled_sheet() {
1677 let line = vec![NurbCurve2D::line(v2(-1., 0.), v2(1., 0.)).unwrap()];
1678 let arc =
1679 vec![NurbCurve2D::arc(v2(1., 0.), v2(1., 1.), v2(0., 1.), sqrt2_over_2()).unwrap()];
1680 let open = |name: &str, plane, curves| Section {
1681 plane,
1682 profile: Profile::open(curves).with_prefix(&format!("{name},")),
1683 name: name.to_string(),
1684 matched: Vec::new(),
1685 };
1686 let mut model = NamedModel::new();
1687 let namer = Namer::new("loft", "l").unwrap();
1688 let built = loft(
1689 &mut model,
1690 &namer,
1691 None,
1692 &[open("a", level(0.0), line), open("b", level(1.0), arc)],
1693 &Ends::CAPPED,
1694 &[],
1695 )
1696 .unwrap();
1697 assert!(built.solid.is_none());
1698 model.check_names().unwrap();
1699 assert_model_valid_sheets(model.topology());
1700 assert_eq!(model.topology().faces.len(), 1);
1701 }
1702
1703 #[test]
1705 fn loft_of_one_profile_is_refused() {
1706 let mut model = NamedModel::new();
1707 let namer = Namer::new("loft", "l").unwrap();
1708 let only = section("a", level(0.0), regular(4, 1.0, 0.0));
1709 assert!(
1710 loft(
1711 &mut model,
1712 &namer,
1713 Some("loft(l)"),
1714 &[only],
1715 &Ends::CAPPED,
1716 &[]
1717 )
1718 .is_err()
1719 );
1720 }
1721
1722 fn guide(name: &str, points: [[f64; 3]; 3]) -> Chain<NurbCurve3D> {
1726 let f = Scalar::from_f64;
1727 let curve = NurbCurve::try_new(
1728 2,
1729 points
1730 .iter()
1731 .map(|p| {
1732 geop_core_math::vector::Vector4::from_array([f(p[0]), f(p[1]), f(p[2]), f(1.0)])
1733 })
1734 .collect(),
1735 vec![f(0.), f(0.), f(0.), f(1.), f(1.), f(1.)],
1736 )
1737 .unwrap();
1738 Chain {
1739 name: name.into(),
1740 curves: vec![curve],
1741 curve_names: vec![format!("{name},c")],
1742 joint_names: vec![format!("{name},s"), format!("{name},e")],
1743 }
1744 }
1745
1746 fn guided_loft(sections: &[Section], guides: &[Chain<NurbCurve3D>]) -> NamedModel {
1749 let mut model = NamedModel::new();
1750 let namer = Namer::new("loft", "l").unwrap();
1751 loft(
1752 &mut model,
1753 &namer,
1754 Some(&namer.root()),
1755 sections,
1756 &Ends::CAPPED,
1757 guides,
1758 )
1759 .unwrap();
1760 model.check_names().unwrap();
1761 assert_model_valid(model.topology());
1762 model
1763 }
1764
1765 fn assert_follows(model: &NamedModel, name: &str, guide: &Chain<NurbCurve3D>) {
1768 let edge = model.edge_id(name).unwrap();
1769 let curve = &model.topology().edges[&edge].curve;
1770 for i in 0..=8 {
1771 let t = Scalar::from_f64(i as f64 / 8.0);
1772 let (a, b) = (
1773 curve.evaluate(t).unwrap(),
1774 guide.curves[0].evaluate(t).unwrap(),
1775 );
1776 for k in 0..3 {
1779 assert!(
1780 (a[k].to_f64() - b[k].to_f64()).abs() < 1e-12,
1781 "at {t:?}: {a:?} vs {b:?}"
1782 );
1783 }
1784 }
1785 }
1786
1787 #[test]
1791 fn loft_with_one_guide() {
1792 let g = guide("g", [[1., 1., 0.], [2., 2., 1.], [1., 1., 2.]]);
1793 let model = guided_loft(
1794 &[
1795 section("a", level(0.0), square(1.0)),
1796 section("b", level(2.0), square(1.0)),
1797 ],
1798 std::slice::from_ref(&g),
1799 );
1800 assert_eq!(model.topology().faces.len(), 4 + 2);
1801 assert_follows(&model, "loft(l,a,p2)", &g);
1802 let edge = model.edge_id("loft(l,a,p0)").unwrap();
1805 let middle = model.topology().edges[&edge]
1806 .curve
1807 .evaluate(Scalar::from_f64(0.5))
1808 .unwrap();
1809 for (k, want) in [-0.5, -0.5, 1.0].into_iter().enumerate() {
1810 assert!((middle[k].to_f64() - want).abs() < 1e-12, "{middle:?}");
1811 }
1812 }
1813
1814 #[test]
1817 fn loft_with_two_guides() {
1818 let g = guide("g", [[1., 1., 0.], [2., 2., 1.], [1., 1., 2.]]);
1819 let h = guide("h", [[-1., -1., 0.], [-2., -2., 1.], [-1., -1., 2.]]);
1820 let model = guided_loft(
1821 &[
1822 section("a", level(0.0), square(1.0)),
1823 section("b", level(2.0), square(1.0)),
1824 ],
1825 &[h.clone(), g.clone()],
1826 );
1827 assert_follows(&model, "loft(l,a,p2)", &g);
1828 assert_follows(&model, "loft(l,a,p0)", &h);
1829 }
1830
1831 #[test]
1834 fn guide_splits_the_sides_it_meets() {
1835 let g = guide("g", [[1., 0., 0.], [1.5, 0., 1.], [1., 0., 2.]]);
1836 let model = guided_loft(
1837 &[
1838 section("a", level(0.0), square(1.0)),
1839 section("b", level(2.0), circle(1.0)),
1840 ],
1841 std::slice::from_ref(&g),
1842 );
1843 assert_follows(&model, "loft(l,a,g)", &g);
1844 }
1845
1846 #[test]
1849 fn loft_with_a_guide_through_three_profiles() {
1850 let g = guide("g", [[1., 1., 0.], [1.4, 1.4, 1.], [1., 1., 2.]]);
1851 let model = guided_loft(
1852 &[
1853 section("a", level(0.0), square(1.0)),
1854 section("b", level(1.0), square(1.2)),
1855 section("c", level(2.0), square(1.0)),
1856 ],
1857 std::slice::from_ref(&g),
1858 );
1859 assert_eq!(model.topology().faces.len(), 2 * 4 + 2);
1860 let corner = model.vertex_id("loft(l,a,p2,b)").unwrap();
1861 let p = model.topology().get_vertex(corner).unwrap().point;
1862 assert!(p.could_be_equal(&v3(1.2, 1.2, 1.0)), "{p:?}");
1863 }
1864
1865 #[test]
1867 fn guided_loft_refuses_what_it_cannot_build() {
1868 let sections = [
1869 section("a", level(0.0), square(1.0)),
1870 section("b", level(2.0), square(1.0)),
1871 ];
1872 let refused = |sections: &[Section], guides: &[Chain<NurbCurve3D>], says: &str| {
1873 let mut model = NamedModel::new();
1874 let namer = Namer::new("loft", "l").unwrap();
1875 let error = loft(
1876 &mut model,
1877 &namer,
1878 Some("loft(l)"),
1879 sections,
1880 &Ends::CAPPED,
1881 guides,
1882 )
1883 .unwrap_err();
1884 assert!(error.root_message().contains(says), "{error:?}");
1885 };
1886 refused(
1888 §ions,
1889 &[guide("off", [[1., 1., 0.5], [2., 2., 1.], [1., 1., 2.]])],
1890 "the guide off has to run from the profile a to the profile b",
1891 );
1892 refused(
1894 §ions,
1895 &[guide("out", [[3., 1., 0.], [2., 2., 1.], [1., 1., 2.]])],
1896 "is on none of the profile's curves",
1897 );
1898 let mut matched = sections.to_vec();
1900 matched[0].matched = vec!["a,p0".into()];
1901 matched[1].matched = vec!["b,p0".into()];
1902 refused(
1903 &matched,
1904 &[guide("g", [[1., 1., 0.], [2., 2., 1.], [1., 1., 2.]])],
1905 "use one or the other",
1906 );
1907 let four: Vec<Chain<NurbCurve3D>> = (0..4)
1909 .map(|k| {
1910 let (x, y) = [(1., 1.), (-1., 1.), (-1., -1.), (1., -1.)][k];
1911 guide(
1912 &format!("g{k}"),
1913 [[x, y, 0.], [2. * x, 2. * y, 1.], [x, y, 2.]],
1914 )
1915 })
1916 .collect();
1917 refused(§ions, &four, "at most 3");
1918 }
1919}