1use ogeom_algo::{
16 Built, History, edge_vertices, make_cone, make_cylinder, make_edge, make_edge_between,
17 make_face_with_pcurves, make_solid, make_torus, make_vertex, sew,
18};
19use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
20use ogeom_geom::Curve3d as _;
21use ogeom_geom::Transformable as _;
22use ogeom_geom::{
23 CircleCurve, Curve, Line2d, LineCurve, PlaneSurface, SurfaceGeometry, TorusSurface,
24};
25use ogeom_math::{Circle, Direction, Frame, Plane, Point, Point2, Torus, Transform, Vector};
26use ogeom_topo::{EdgeData, EdgeRepr, Filter, Model, Shape, ShapeType, VertexData, explore};
27
28pub fn make_pipe(
39 model: &mut Model,
40 spine: &Shape,
41 radius: f64,
42 tol: Tolerances,
43) -> OgeomResult<Built> {
44 if !radius.is_finite() || radius <= tol.confusion() {
45 ogeom_bail!(Construction, "a pipe of radius {radius} holds nothing");
46 }
47 let (curve, range) = {
48 let Some(data) = model.node(spine).and_then(|n| n.data().as_edge()) else {
49 ogeom_bail!(Construction, "a pipe runs along an edge");
50 };
51 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
52 ogeom_bail!(Construction, "the spine has no curve");
53 };
54 let Some(geometry) = model.geometry().curve(*curve) else {
55 ogeom_bail!(Dangling, "curve is not in this model");
56 };
57 (geometry.clone(), *range)
58 };
59 let mut built = match &curve {
60 Curve::Line(line) => {
61 let start = curve.point_at(range.0, tol)?;
62 let length = range.1 - range.0;
63 let frame = Frame::about(start, line.axis().direction);
64 make_cylinder(model, frame, radius, length, tol)?
65 }
66 Curve::Circle(c) => {
67 let circle = c.circle();
68 if radius >= circle.radius() - tol.confusion() {
69 ogeom_bail!(
70 Construction,
71 "a tube of radius {radius} swallows its spine of radius {}",
72 circle.radius()
73 );
74 }
75 let closed = curve
76 .point_at(range.0, tol)?
77 .distance(curve.point_at(range.1, tol)?)
78 <= tol.confusion();
79 if closed {
80 make_torus(model, circle.frame(), circle.radius(), radius, tol)?
81 } else {
82 pipe_segment(model, circle, range, radius, tol)?
83 }
84 }
85 _ => ogeom_bail!(
86 Construction,
87 "a pipe along a free-form spine needs the sweep-surface \
88 machinery; see docs/PARITY.md, offset.sweeps"
89 ),
90 };
91 built.history.generate(spine, built.shape.clone());
92 Ok(built)
93}
94
95fn pipe_segment(
104 model: &mut Model,
105 spine: Circle,
106 range: (f64, f64),
107 radius: f64,
108 tol: Tolerances,
109) -> OgeomResult<Built> {
110 let frame = spine.frame();
111 let (x, y, z) = (frame.x().vector(), frame.y().vector(), frame.z().vector());
112 let major = spine.radius();
113 let radial = |u: f64| x * u.cos() + y * u.sin();
114 let tangent = |u: f64| x * -u.sin() + y * u.cos();
115 let tube_point = |u: f64, v: f64| {
116 frame.origin() + radial(u) * radius.mul_add(v.cos(), major) + z * (radius * v.sin())
117 };
118 let pi = core::f64::consts::PI;
119 let tau = core::f64::consts::TAU;
120
121 let torus: SurfaceGeometry = TorusSurface::new(Torus::new(frame, major, radius, tol)?).into();
122 let surface_id = model.geometry_mut().add_surface(torus);
123
124 let ends = [range.0, range.1];
126 let mut verts: Vec<Vec<Shape>> = Vec::new();
127 for &u in &ends {
128 verts.push(vec![
129 make_vertex(model, tube_point(u, 0.0)).shape,
130 make_vertex(model, tube_point(u, pi)).shape,
131 ]);
132 }
133
134 let mut tube_arcs: Vec<Vec<Shape>> = Vec::new();
138 for (k, &u) in ends.iter().enumerate() {
139 let centre = frame.origin() + radial(u) * major;
140 let circle = Circle::new(
141 Frame::new(
142 centre,
143 Direction::new(-tangent(u), tol)?,
144 Direction::new(radial(u), tol)?,
145 tol,
146 )?,
147 radius,
148 tol,
149 )?;
150 let curve = Curve::Circle(CircleCurve::new(circle));
151 let arcs = vec![
152 make_edge_between(
153 model,
154 curve.clone(),
155 (0.0, pi),
156 &verts[k][0],
157 &verts[k][1],
158 tol,
159 )?
160 .shape,
161 make_edge_between(model, curve, (pi, tau), &verts[k][1], &verts[k][0], tol)?.shape,
162 ];
163 let column = Line2d::over(
165 ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
166 0.0,
167 tau,
168 )?;
169 ogeom_algo::attach_pcurve(
170 model,
171 &arcs[0],
172 column.into(),
173 surface_id,
174 ogeom_topo::Location::identity(),
175 (0.0, pi),
176 )?;
177 ogeom_algo::attach_pcurve(
178 model,
179 &arcs[1],
180 column.into(),
181 surface_id,
182 ogeom_topo::Location::identity(),
183 (pi, tau),
184 )?;
185 tube_arcs.push(arcs);
186 }
187
188 let parallel = |model: &mut Model, v: f64, from: &Shape, to: &Shape| -> OgeomResult<Shape> {
191 let height = radius * v.sin();
192 let ring = radius.mul_add(v.cos(), major);
193 let circle = Circle::new(
194 Frame::new(frame.origin() + z * height, frame.z(), frame.x(), tol)?,
195 ring,
196 tol,
197 )?;
198 let curve = Curve::Circle(CircleCurve::new(circle));
199 Ok(make_edge_between(model, curve, range, from, to, tol)?.shape)
200 };
201 let row = |v: f64| -> OgeomResult<Line2d> {
202 Line2d::over(
203 ogeom_math::Axis2::new(Point2::new(0.0, v), ogeom_math::Direction2::X),
204 range.0 - 1.0,
205 range.1 + 1.0,
206 )
207 };
208 let inner = parallel(model, pi, &verts[0][1], &verts[1][1])?;
209 ogeom_algo::attach_pcurve(
210 model,
211 &inner,
212 row(pi)?.into(),
213 surface_id,
214 ogeom_topo::Location::identity(),
215 range,
216 )?;
217 let outer = parallel(model, 0.0, &verts[0][0], &verts[1][0])?;
221 ogeom_algo::attach_seam(
222 model,
223 &outer,
224 row(0.0)?.into(),
225 row(tau)?.into(),
226 surface_id,
227 ogeom_topo::Location::identity(),
228 range,
229 )?;
230
231 let lower = {
235 let wire = ogeom_algo::make_wire(
236 model,
237 &[
238 outer.clone(),
239 tube_arcs[1][0].clone(),
240 inner.reversed(),
241 tube_arcs[0][0].reversed(),
242 ],
243 tol,
244 )?
245 .shape;
246 ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape
247 };
248 let upper = {
249 let wire = ogeom_algo::make_wire(
250 model,
251 &[
252 inner.clone(),
253 tube_arcs[1][1].clone(),
254 outer.reversed(),
255 tube_arcs[0][1].reversed(),
256 ],
257 tol,
258 )?
259 .shape;
260 ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape
261 };
262
263 let mut caps: Vec<Shape> = Vec::new();
266 for (k, &u) in ends.iter().enumerate() {
267 let outward = if k == 0 { -tangent(u) } else { tangent(u) };
268 let centre = frame.origin() + radial(u) * major;
269 let plane = Plane::through(centre, Direction::new(outward, tol)?);
270 let reach = (major + radius) * 2.0;
271 let surface: SurfaceGeometry =
272 PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
273 let ring = if k == 0 {
276 vec![tube_arcs[k][0].clone(), tube_arcs[k][1].clone()]
277 } else {
278 vec![tube_arcs[k][1].reversed(), tube_arcs[k][0].reversed()]
279 };
280 caps.push(make_face_with_pcurves(model, surface, &[ring], tol)?.shape);
281 }
282
283 let faces = [lower, upper, caps[0].clone(), caps[1].clone()];
284 let sewn = sew(model, &faces, tol)?;
285 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
286 ogeom_bail!(Construction, "the pipe segment did not close");
287 }
288 make_solid(model, std::slice::from_ref(&sewn.shells[0]))
289}
290
291pub fn make_loft(
307 model: &mut Model,
308 bottom: &Shape,
309 top: &Shape,
310 tol: Tolerances,
311) -> OgeomResult<Built> {
312 match (model.kind_of(bottom)?, model.kind_of(top)?) {
315 (ShapeType::Wire, ShapeType::Vertex) => {
316 return loft_to_point(model, bottom, top, tol);
317 }
318 (ShapeType::Vertex, ShapeType::Wire) => {
319 let mut built = loft_to_point(model, top, bottom, tol)?;
320 built.history.generate(bottom, built.shape.clone());
322 return Ok(built);
323 }
324 _ => {}
325 }
326 for wire in [bottom, top] {
327 if model.kind_of(wire)? != ShapeType::Wire {
328 ogeom_bail!(Construction, "a loft runs between wires");
329 }
330 if !ogeom_algo::is_wire_closed(model, wire, tol)? {
331 ogeom_bail!(Construction, "a loft section must be closed");
332 }
333 }
334 let circle_of = |model: &Model, wire: &Shape| -> OgeomResult<Option<Circle>> {
335 let edges = explore(model, wire, Filter::OfType(ShapeType::Edge))?;
336 if edges.len() != 1 {
337 return Ok(None);
338 }
339 let Some(data) = model.node(&edges[0]).and_then(|n| n.data().as_edge()) else {
340 return Ok(None);
341 };
342 let Some(EdgeRepr::Curve3d { curve, .. }) = data.curve3d() else {
343 return Ok(None);
344 };
345 match model.geometry().curve(*curve) {
346 Some(Curve::Circle(c)) => Ok(Some(c.circle())),
347 _ => Ok(None),
348 }
349 };
350
351 if let (Some(lower), Some(upper)) = (circle_of(model, bottom)?, circle_of(model, top)?) {
352 let axis = lower.frame().z().vector();
354 let rise = upper.centre() - lower.centre();
355 let height = rise.dot(axis);
356 if rise.cross(axis).magnitude() > tol.confusion() * 10.0 || height.abs() <= tol.confusion()
357 {
358 ogeom_bail!(
359 Construction,
360 "lofted circles must be coaxial on parallel planes; the \
361 oblique loft needs the sweep machinery; see the deferred \
362 table"
363 );
364 }
365 let frame = if height > 0.0 {
366 Frame::new(lower.centre(), lower.frame().z(), lower.frame().x(), tol)?
367 } else {
368 Frame::new(
369 lower.centre(),
370 lower.frame().z().reversed(),
371 lower.frame().x(),
372 tol,
373 )?
374 };
375 let mut built = if (lower.radius() - upper.radius()).abs() <= tol.confusion() {
376 make_cylinder(model, frame, lower.radius(), height.abs(), tol)?
377 } else {
378 make_cone(
379 model,
380 frame,
381 lower.radius(),
382 upper.radius(),
383 height.abs(),
384 tol,
385 )?
386 };
387 built.history.generate(bottom, built.shape.clone());
388 built.history.generate(top, built.shape.clone());
389 return Ok(built);
390 }
391
392 let corners_of = |model: &Model, wire: &Shape| -> OgeomResult<Vec<Point>> {
394 let mut out = Vec::new();
395 for edge in explore(model, wire, Filter::OfType(ShapeType::Edge))? {
396 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
397 ogeom_bail!(Construction, "a section edge holds no data");
398 };
399 let Some(EdgeRepr::Curve3d { curve, .. }) = data.curve3d() else {
400 ogeom_bail!(Construction, "a section edge has no curve");
401 };
402 let Some(Curve::Line(_)) = model.geometry().curve(*curve) else {
403 ogeom_bail!(
404 Construction,
405 "a mixed or curved section needs the skinning machinery; see \
406 docs/PARITY.md, offset.sweeps"
407 );
408 };
409 let Some((sv, _)) = edge_vertices(model, &edge)? else {
410 ogeom_bail!(Construction, "a section edge has no vertices");
411 };
412 let Some(data) = model.node(&sv).and_then(|n| n.data().as_vertex()) else {
413 ogeom_bail!(Construction, "a section vertex holds no point");
414 };
415 out.push(sv.transform(model.datums())?.apply(data.point));
416 }
417 Ok(out)
418 };
419 let low = corners_of(model, bottom)?;
420 let high = corners_of(model, top)?;
421 if low.len() != high.len() {
422 ogeom_bail!(
423 Construction,
424 "lofted sections must have the same corner count, found {} and {}",
425 low.len(),
426 high.len()
427 );
428 }
429 let n = low.len();
430 let centroid = {
431 let mut c = Vector::new(0.0, 0.0, 0.0);
432 for p in low.iter().chain(high.iter()) {
433 c += p.to_vector();
434 }
435 #[allow(clippy::cast_precision_loss)]
436 let count = 2.0 * n as f64;
437 Point::from_vector(c / count)
438 };
439
440 let vl: Vec<Shape> = low.iter().map(|p| make_vertex(model, *p).shape).collect();
442 let vh: Vec<Shape> = high.iter().map(|p| make_vertex(model, *p).shape).collect();
443 let seg = |model: &mut Model, a: (&Shape, Point), b: (&Shape, Point)| -> OgeomResult<Shape> {
444 let line = LineCurve::segment(a.1, b.1, tol)?;
445 let curve = Curve::Line(line);
446 let domain = curve.domain();
447 Ok(make_edge_between(model, curve, domain, a.0, b.0, tol)?.shape)
448 };
449 let mut low_edges = Vec::with_capacity(n);
450 let mut high_edges = Vec::with_capacity(n);
451 let mut rails = Vec::with_capacity(n);
452 for i in 0..n {
453 let j = (i + 1) % n;
454 low_edges.push(seg(model, (&vl[i], low[i]), (&vl[j], low[j]))?);
455 high_edges.push(seg(model, (&vh[i], high[i]), (&vh[j], high[j]))?);
456 rails.push(seg(model, (&vl[i], low[i]), (&vh[i], high[i]))?);
457 }
458
459 let planar = |model: &mut Model, corners: &[Point], edges: Vec<Shape>| -> OgeomResult<Shape> {
460 let normal = {
461 let mut n = (corners[1] - corners[0]).cross(corners[2] - corners[0]);
462 let m = n.magnitude();
463 if m <= tol.confusion() {
464 ogeom_bail!(Construction, "a loft wall is degenerate");
465 }
466 n /= m;
467 if n.dot(corners[0] - centroid) < 0.0 {
468 -n
469 } else {
470 n
471 }
472 };
473 let skew = corners.iter().any(|p| {
474 Plane::through(
475 corners[0],
476 Direction::new(normal, tol).unwrap_or(Direction::Z),
477 )
478 .distance_to(*p)
479 > tol.confusion() * 10.0
480 });
481 if skew {
482 if corners.len() != 4 || edges.len() != 4 {
486 ogeom_bail!(Construction, "a skew ruled wall has four corners");
487 }
488 return bilinear_wall(model, corners, &edges, centroid, tol);
489 }
490 let plane = Plane::through(corners[0], Direction::new(normal, tol)?);
491 let mut reach = 1.0_f64;
492 for p in corners {
493 reach = reach.max(p.distance(corners[0]) * 2.0);
494 }
495 let surface: SurfaceGeometry =
496 PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
497 let turn = (0..corners.len())
500 .map(|i| {
501 corners[i]
502 .to_vector()
503 .cross(corners[(i + 1) % corners.len()].to_vector())
504 })
505 .fold(Vector::new(0.0, 0.0, 0.0), |sum, v| sum + v);
506 let ring = if turn.dot(normal) < 0.0 {
507 walked_back(&edges)
508 } else {
509 edges
510 };
511 Ok(make_face_with_pcurves(model, surface, &[ring], tol)?.shape)
512 };
513
514 let mut faces: Vec<Shape> = Vec::with_capacity(n + 2);
515 for i in 0..n {
516 let j = (i + 1) % n;
517 faces.push(planar(
518 model,
519 &[low[i], low[j], high[j], high[i]],
520 vec![
521 low_edges[i].clone(),
522 rails[j].clone(),
523 high_edges[i].reversed(),
524 rails[i].reversed(),
525 ],
526 )?);
527 }
528 faces.push(planar(model, &low, low_edges.clone())?);
529 faces.push(planar(model, &high, high_edges.clone())?);
530
531 let sewn = sew(model, &faces, tol)?;
532 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
533 ogeom_bail!(Construction, "the loft did not close");
534 }
535 let mut built = make_solid(model, std::slice::from_ref(&sewn.shells[0]))?;
536 built.history.generate(bottom, built.shape.clone());
537 built.history.generate(top, built.shape.clone());
538 Ok(built)
539}
540
541fn bilinear_wall(
551 model: &mut Model,
552 corners: &[Point],
553 edges: &[Shape],
554 centroid: Point,
555 tol: Tolerances,
556) -> OgeomResult<Shape> {
557 use ogeom_geom::Surface as _;
558 let (p00, p10, p11, p01) = (corners[0], corners[1], corners[2], corners[3]);
559 let grid = ogeom_math::ControlGrid::new(vec![p00, p01, p10, p11], 2, 2)?;
560 let line = ogeom_math::KnotVector::clamped_uniform(1, 2)?;
561 let patch = ogeom_geom::BSplineSurface::new(line.clone(), line, &grid, tol)?;
562 let outward = {
563 let (du, dv) = patch.d1_at(0.5, 0.5, tol)?;
564 let centre = patch.point_at(0.5, 0.5, tol)?;
565 du.cross(dv).dot(centre - centroid) >= 0.0
566 };
567 let surface_id = model
568 .geometry_mut()
569 .add_surface(SurfaceGeometry::BSpline(patch));
570
571 let sides: [(Point2, Point2); 4] = [
576 (Point2::new(0.0, 0.0), Point2::new(1.0, 0.0)),
577 (Point2::new(1.0, 0.0), Point2::new(1.0, 1.0)),
578 (Point2::new(0.0, 1.0), Point2::new(1.0, 1.0)),
579 (Point2::new(0.0, 0.0), Point2::new(0.0, 1.0)),
580 ];
581 for (edge, (from, to)) in edges.iter().zip(sides) {
582 let range = {
583 let Some(node) = model.node(edge) else {
584 ogeom_bail!(Dangling, "a loft edge is not in this model");
585 };
586 let Some(data) = node.data().as_edge() else {
587 ogeom_bail!(Construction, "a loft edge holds no edge data");
588 };
589 let Some(EdgeRepr::Curve3d { range, .. }) = data.curve3d() else {
590 ogeom_bail!(Construction, "a loft edge has no curve");
591 };
592 *range
593 };
594 let knots = ogeom_math::KnotVector::new(vec![range.0, range.0, range.1, range.1], 1)?;
595 let pcurve = ogeom_geom::BSpline2d::new(knots, vec![from, to], tol)?;
596 ogeom_algo::attach_pcurve(
597 model,
598 edge,
599 pcurve.into(),
600 surface_id,
601 ogeom_topo::Location::identity(),
602 range,
603 )?;
604 }
605 let wire = ogeom_algo::make_wire(model, edges, tol)?.shape;
606 let face = ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
607 Ok(if outward { face } else { face.reversed() })
608}
609
610struct Skin<'a> {
613 rows: Vec<Vec<Point>>,
616 round: bool,
618 by_spacing: bool,
621 traced: Option<Traced<'a>>,
626}
627
628struct Traced<'a> {
630 point: Box<dyn Fn(f64, f64) -> OgeomResult<Point> + 'a>,
631 sampling: ogeom_geom::fit::Sampling,
632}
633
634#[derive(Clone)]
637enum Section {
638 Loop(ArcLoop),
639 Point(Point),
640 Halfway(ArcLoop, Point),
642}
643
644impl Section {
645 fn at(&self, f: f64) -> Point {
647 match self {
648 Self::Loop(arc) => arc.at(f),
649 Self::Point(p) => *p,
650 Self::Halfway(arc, to) => {
651 Point::from_vector((arc.at(f).to_vector() + to.to_vector()) * 0.5)
652 }
653 }
654 }
655}
656
657const AROUND_SECTION: usize = 48;
660
661fn fractions(n: usize) -> Vec<f64> {
663 (0..=n)
664 .map(|i| {
665 #[allow(clippy::cast_precision_loss, reason = "a small count")]
666 let f = i as f64 / n as f64;
667 f
668 })
669 .collect()
670}
671
672impl<'a> Skin<'a> {
673 fn rows(rows: Vec<Vec<Point>>) -> Self {
676 Self {
677 rows,
678 round: true,
679 by_spacing: false,
680 traced: None,
681 }
682 }
683
684 fn columns(
693 column: impl Fn(f64) -> OgeomResult<Vec<Point>> + 'a,
694 us: Vec<f64>,
695 round: bool,
696 by_spacing: bool,
697 closed_v: bool,
698 ) -> OgeomResult<Self> {
699 let sampled: Vec<Vec<Point>> = us[..us.len() - usize::from(round)]
700 .iter()
701 .map(|u| column(*u))
702 .collect::<OgeomResult<_>>()?;
703 let count = sampled[0].len();
704 let rows: Vec<Vec<Point>> = (0..count)
705 .map(|j| sampled.iter().map(|c| c[j]).collect())
706 .collect();
707 let mut grid: Vec<Vec<Point>> = rows
708 .iter()
709 .map(|r| {
710 let mut r = r.clone();
711 if round {
712 r.push(r[0]);
713 }
714 r
715 })
716 .collect();
717 if closed_v {
718 grid.push(grid[0].clone());
719 }
720 let bare = Self {
721 rows: rows.clone(),
722 round,
723 by_spacing,
724 traced: None,
725 };
726 let (_, vs) = ogeom_geom::fit::grid_parameters(&grid, (false, by_spacing))?;
727 if vs.windows(2).any(|w| w[1] <= w[0]) {
728 return Ok(bare);
731 }
732 let row_vs = vs.clone();
733 let (u0, u1) = (us[0], us[us.len() - 1]);
734 let held: std::cell::RefCell<ogeom_core::FastMap<u64, Vec<Point>>> =
735 std::cell::RefCell::default();
736 let point = move |u: f64, v: f64| -> OgeomResult<Point> {
737 let Some(j) = row_vs.iter().position(|w| w.to_bits() == v.to_bits()) else {
738 ogeom_bail!(Construction, "a skin is known across only at its rows");
739 };
740 let u = if round && u >= u1 { u0 } else { u };
743 if let Some(c) = held.borrow().get(&u.to_bits()) {
744 return Ok(c[j % count]);
745 }
746 let c = column(u)?;
747 if c.len() != count {
748 ogeom_bail!(Construction, "a skin's column changed its length");
749 }
750 let p = c[j % count];
751 held.borrow_mut().insert(u.to_bits(), c);
752 Ok(p)
753 };
754 Ok(Self {
755 traced: Some(Traced {
756 point: Box::new(point),
757 sampling: ogeom_geom::fit::Sampling {
758 us,
759 vs,
760 between: (true, false),
761 closed_v,
762 most: 512,
763 },
764 }),
765 ..bare
766 })
767 }
768
769 fn swept(
777 point: impl Fn(f64, f64) -> OgeomResult<Point> + 'a,
778 us: Vec<f64>,
779 stations: (usize, usize),
780 round: bool,
781 ) -> OgeomResult<Self> {
782 let (u0, u1) = (us[0], us[us.len() - 1]);
783 #[allow(clippy::cast_precision_loss, reason = "station counts")]
784 let (first, steps) = (stations.0 as f64, (stations.1 - stations.0) as f64);
785 if steps < 1.0 {
786 ogeom_bail!(Construction, "a swept skin needs two stations");
787 }
788 let vs: Vec<f64> = (stations.0..=stations.1)
789 .map(|i| {
790 #[allow(clippy::cast_precision_loss, reason = "station counts")]
791 let step = (i - stations.0) as f64;
792 step / steps
793 })
794 .collect();
795 let point = move |u: f64, v: f64| -> OgeomResult<Point> {
798 let s = first + v * steps;
799 let whole = s.round();
800 let s = if (s - whole).abs() <= 1e-9 { whole } else { s };
801 point(if round && u >= u1 { u0 } else { u }, s)
802 };
803 let rows: Vec<Vec<Point>> = vs
804 .iter()
805 .map(|v| {
806 us[..us.len() - usize::from(round)]
807 .iter()
808 .map(|u| point(*u, *v))
809 .collect::<OgeomResult<Vec<Point>>>()
810 })
811 .collect::<OgeomResult<_>>()?;
812 Ok(Self {
813 rows,
814 round,
815 by_spacing: false,
816 traced: Some(Traced {
817 point: Box::new(point),
818 sampling: ogeom_geom::fit::Sampling {
819 us,
820 vs,
821 between: (true, true),
822 closed_v: false,
823 most: 512,
824 },
825 }),
826 })
827 }
828
829 fn sections(sections: Vec<Section>, closed: bool) -> OgeomResult<Self> {
832 Self::columns(
833 move |u| Ok(sections.iter().map(|s| s.at(u)).collect()),
834 fractions(AROUND_SECTION),
835 true,
836 false,
837 closed,
838 )
839 }
840
841 fn fit(
846 &self,
847 closed_v: bool,
848 tolerance: f64,
849 tol: Tolerances,
850 ) -> OgeomResult<ogeom_geom::fit::Fitted<ogeom_geom::BSplineSurface>> {
851 match &self.traced {
852 None => {
853 let mut grid: Vec<Vec<Point>> = self
854 .rows
855 .iter()
856 .map(|row| {
857 let mut r = row.clone();
858 if self.round {
859 r.push(row[0]);
860 }
861 r
862 })
863 .collect();
864 if closed_v {
865 grid.push(grid[0].clone());
866 ogeom_geom::fit::fit_surface_grid_closed_v(&grid, 3, tolerance, tol)
867 } else if self.by_spacing {
868 ogeom_geom::fit::fit_surface_grid_sections(&grid, 3, tolerance, tol)
869 } else {
870 ogeom_geom::fit::fit_surface_grid(&grid, 3, tolerance, tol)
871 }
872 }
873 Some(traced) => {
874 let mut sampling = traced.sampling.clone();
875 sampling.closed_v = closed_v;
876 ogeom_geom::fit::fit_surface_sampled(
877 |u, v| (traced.point)(u, v),
878 &sampling,
879 3,
880 tolerance,
881 tol,
882 )
883 }
884 }
885 }
886}
887
888#[derive(Clone)]
890struct ArcLoop {
891 dense: Vec<Point>,
892 lengths: Vec<f64>,
894 total: f64,
896}
897
898impl ArcLoop {
899 fn new(dense: Vec<Point>) -> Self {
900 let mut lengths = Vec::with_capacity(dense.len());
901 lengths.push(0.0);
902 for w in dense.windows(2) {
903 let last = lengths[lengths.len() - 1];
904 lengths.push(last + w[0].distance(w[1]));
905 }
906 let total = lengths[lengths.len() - 1] + dense[dense.len() - 1].distance(dense[0]);
907 Self {
908 dense,
909 lengths,
910 total,
911 }
912 }
913
914 fn at(&self, f: f64) -> Point {
917 let f = if (0.0..1.0).contains(&f) { f } else { 0.0 };
918 let target = self.total * f;
919 let n = self.dense.len();
920 let cursor = self.lengths[1..]
921 .partition_point(|l| *l < target)
922 .min(n - 1);
923 let (a, b) = (self.dense[cursor], self.dense[(cursor + 1) % n]);
924 let la = self.lengths[cursor];
925 let lb = if cursor + 1 < n {
926 self.lengths[cursor + 1]
927 } else {
928 self.total
929 };
930 let t = if lb > la {
931 (target - la) / (lb - la)
932 } else {
933 0.0
934 };
935 a + (b - a) * t.clamp(0.0, 1.0)
936 }
937}
938
939struct SkinnedWall {
943 face: Shape,
944 ring0: Shape,
945 ring1: Shape,
946 curve0: ogeom_geom::Curve,
947 curve1: ogeom_geom::Curve,
948 u_dom: (f64, f64),
949}
950
951fn skinned_wall(
961 model: &mut Model,
962 skin: &Skin<'_>,
963 shared: (Option<&Shape>, Option<&Shape>),
964 tolerance: f64,
965 tol: Tolerances,
966) -> OgeomResult<SkinnedWall> {
967 use ogeom_geom::Surface as _;
968 let rows = &skin.rows;
969 let fitted = skin.fit(false, tolerance, tol)?;
970 if !fitted.met {
971 ogeom_bail!(
972 NotDone,
973 "the skin reached {} against a target of {tolerance}",
974 fitted.error
975 );
976 }
977 let surface = fitted.curve;
978 let (u_knots, v_knots) = (surface.u_knots().clone(), surface.v_knots().clone());
979 let (k, l, net) = {
980 let grid = surface.grid();
981 let net: Vec<Point> = grid.points().iter().map(|w| (*w).point()).collect();
982 (grid.u_count(), grid.v_count(), net)
983 };
984 let point_at = |i: usize, j: usize| -> Point { net[i * l + j] };
985 let (u_dom, v_dom) = surface.domain();
986
987 let border_v = |j: usize| -> OgeomResult<ogeom_geom::Curve> {
990 let control: Vec<Point> = (0..k).map(|i| point_at(i, j)).collect();
991 Ok(ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
992 u_knots.clone(),
993 control,
994 tol,
995 )?))
996 };
997 let seam_curve = {
998 let control: Vec<Point> = (0..l).map(|j| point_at(0, j)).collect();
999 ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
1000 v_knots.clone(),
1001 control,
1002 tol,
1003 )?)
1004 };
1005
1006 let surface_geo: SurfaceGeometry = surface.into();
1007 let surface_id = model.geometry_mut().add_surface(surface_geo.clone());
1008
1009 let slack = fitted.error + tol.confusion();
1012 let ring_of = |model: &mut Model,
1013 j: usize,
1014 given: Option<&Shape>|
1015 -> OgeomResult<(Shape, ogeom_geom::Curve)> {
1016 match given {
1017 Some(edge) => {
1018 adopt_border(model, edge, &surface_geo, slack, tol)?;
1019 Ok((edge.clone(), spine_curve_of(model, edge)?.0))
1020 }
1021 None => {
1022 let curve = border_v(j)?;
1023 Ok((make_edge(model, curve.clone(), u_dom, tol)?.shape, curve))
1024 }
1025 }
1026 };
1027 let (ring0, curve0) = ring_of(model, 0, shared.0)?;
1028 let (ring1, curve1) = ring_of(model, l - 1, shared.1)?;
1029 let anchor0 = ogeom_algo::edge_vertices(model, &ring0)?
1030 .map(|(a, _)| a)
1031 .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "a skinned ring has no vertex"))?;
1032 let anchor1 = ogeom_algo::edge_vertices(model, &ring1)?
1033 .map(|(a, _)| a)
1034 .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "a skinned ring has no vertex"))?;
1035 let seam = make_edge_between(model, seam_curve, v_dom, &anchor0, &anchor1, tol)?.shape;
1036
1037 let row_line = |v: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1039 Ok(Line2d::over(
1040 ogeom_math::Axis2::new(Point2::new(0.0, v), ogeom_math::Direction2::X),
1041 u_dom.0 - 1.0,
1042 u_dom.1 + 1.0,
1043 )?
1044 .into())
1045 };
1046 let column_line = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1047 Ok(Line2d::over(
1048 ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
1049 v_dom.0 - 1.0,
1050 v_dom.1 + 1.0,
1051 )?
1052 .into())
1053 };
1054 ogeom_algo::attach_pcurve(
1055 model,
1056 &ring0,
1057 row_line(v_dom.0)?,
1058 surface_id,
1059 ogeom_topo::Location::identity(),
1060 u_dom,
1061 )?;
1062 ogeom_algo::attach_pcurve(
1063 model,
1064 &ring1,
1065 row_line(v_dom.1)?,
1066 surface_id,
1067 ogeom_topo::Location::identity(),
1068 u_dom,
1069 )?;
1070 ogeom_algo::attach_seam(
1071 model,
1072 &seam,
1073 column_line(u_dom.0)?,
1074 column_line(u_dom.1)?,
1075 surface_id,
1076 ogeom_topo::Location::identity(),
1077 v_dom,
1078 )?;
1079
1080 let wall = {
1081 let wire = ogeom_algo::make_wire(
1082 model,
1083 &[
1084 ring0.clone(),
1085 seam.clone(),
1086 ring1.reversed(),
1087 seam.reversed(),
1088 ],
1089 tol,
1090 )?
1091 .shape;
1092 let face =
1093 ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
1094 let mid_u = f64::midpoint(u_dom.0, u_dom.1);
1099 let mid_v = f64::midpoint(v_dom.0, v_dom.1);
1100 let s_mid = surface_geo.point_at(mid_u, mid_v, tol)?;
1101 let (du, dv) = surface_geo.d1_at(mid_u, mid_v, tol)?;
1102 if du.cross(dv).dot(s_mid - section_centre(rows, s_mid)) >= 0.0 {
1103 face
1104 } else {
1105 face.reversed()
1106 }
1107 };
1108 Ok(SkinnedWall {
1109 face: wall,
1110 ring0,
1111 ring1,
1112 curve0,
1113 curve1,
1114 u_dom,
1115 })
1116}
1117
1118fn section_centre(rows: &[Vec<Point>], at: Point) -> Point {
1121 let nearest = rows.iter().filter(|row| !row.is_empty()).min_by(|a, b| {
1122 let d = |row: &Vec<Point>| {
1123 row.iter()
1124 .map(|p| p.distance(at))
1125 .fold(f64::INFINITY, f64::min)
1126 };
1127 d(a).total_cmp(&d(b))
1128 });
1129 let Some(row) = nearest else {
1130 return at;
1131 };
1132 let sum = row
1133 .iter()
1134 .fold(Vector::new(0.0, 0.0, 0.0), |acc, p| acc + p.to_vector());
1135 #[allow(clippy::cast_precision_loss)]
1136 Point::from_vector(sum / row.len() as f64)
1137}
1138
1139fn skinned_ring_strip(
1145 model: &mut Model,
1146 skin: &Skin<'_>,
1147 outward_hint: Point,
1148 shared: [Option<&Shape>; 2],
1149 tolerance: f64,
1150 tol: Tolerances,
1151) -> OgeomResult<(Shape, Shape, Shape)> {
1152 use ogeom_geom::Surface as _;
1153 let fitted = skin.fit(true, tolerance, tol)?;
1155 if !fitted.met {
1156 ogeom_bail!(
1157 NotDone,
1158 "the ring strip reached {} against a target of {tolerance}",
1159 fitted.error
1160 );
1161 }
1162 let surface = fitted.curve;
1163 let (u_knots, v_knots) = (surface.u_knots().clone(), surface.v_knots().clone());
1164 let (k, l, net) = {
1165 let grid = surface.grid();
1166 let net: Vec<Point> = grid.points().iter().map(|w| (*w).point()).collect();
1167 (grid.u_count(), grid.v_count(), net)
1168 };
1169 let point_at = |i: usize, j: usize| -> Point { net[i * l + j] };
1170 let (u_dom, v_dom) = surface.domain();
1171
1172 let rail_curve = |i: usize| -> OgeomResult<ogeom_geom::Curve> {
1177 let control: Vec<Point> = (0..l).map(|j| point_at(i, j)).collect();
1178 Ok(ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
1179 v_knots.clone(),
1180 control,
1181 tol,
1182 )?))
1183 };
1184 let seam_curve = {
1185 let control: Vec<Point> = (0..k).map(|i| point_at(i, 0)).collect();
1186 ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
1187 u_knots.clone(),
1188 control,
1189 tol,
1190 )?)
1191 };
1192 let surface_geo: SurfaceGeometry = surface.into();
1193 let surface_id = model.geometry_mut().add_surface(surface_geo.clone());
1194
1195 let slack = fitted.error + tol.confusion();
1203 let rail_of = |model: &mut Model, i: usize, given: Option<&Shape>| -> OgeomResult<Shape> {
1204 let Some(edge) = given else {
1205 let edge = make_edge(model, rail_curve(i)?, v_dom, tol)?.shape;
1206 model.widen(&edge, ogeom_core::Tolerance::new(slack)?)?;
1207 return Ok(edge);
1208 };
1209 let (curve, range) = spine_curve_of(model, edge)?;
1210 let mut off: f64 = 0.0;
1211 for step in 0..=32 {
1212 #[allow(clippy::cast_precision_loss)]
1213 let t = range.0 + (range.1 - range.0) * (step as f64) / 32.0;
1214 let p = curve.point_at(t, tol)?;
1215 off = off.max(ogeom_algo::project_on_surface(&surface_geo, p, 16, tol)?.distance);
1216 }
1217 model.widen(edge, ogeom_core::Tolerance::new(off + slack)?)?;
1218 if let Some((a, b)) = ogeom_algo::edge_vertices(model, edge)? {
1219 for v in [&a, &b] {
1220 model.widen(v, ogeom_core::Tolerance::new(off + slack)?)?;
1221 }
1222 }
1223 Ok(edge.clone())
1224 };
1225 let rail0 = rail_of(model, 0, shared[0])?;
1226 let rail1 = rail_of(model, k - 1, shared[1])?;
1227 let anchor0 = ogeom_algo::edge_vertices(model, &rail0)?
1228 .map(|(a, _)| a)
1229 .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "a strip rail has no vertex"))?;
1230 let anchor1 = ogeom_algo::edge_vertices(model, &rail1)?
1231 .map(|(a, _)| a)
1232 .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "a strip rail has no vertex"))?;
1233 let seam = make_edge_between(model, seam_curve, u_dom, &anchor0, &anchor1, tol)?.shape;
1234
1235 let row_line = |v: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1236 Ok(Line2d::over(
1237 ogeom_math::Axis2::new(Point2::new(0.0, v), ogeom_math::Direction2::X),
1238 u_dom.0 - 1.0,
1239 u_dom.1 + 1.0,
1240 )?
1241 .into())
1242 };
1243 let column_line = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1244 Ok(Line2d::over(
1245 ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
1246 v_dom.0 - 1.0,
1247 v_dom.1 + 1.0,
1248 )?
1249 .into())
1250 };
1251 ogeom_algo::attach_pcurve(
1252 model,
1253 &rail0,
1254 column_line(u_dom.0)?,
1255 surface_id,
1256 ogeom_topo::Location::identity(),
1257 v_dom,
1258 )?;
1259 ogeom_algo::attach_pcurve(
1260 model,
1261 &rail1,
1262 column_line(u_dom.1)?,
1263 surface_id,
1264 ogeom_topo::Location::identity(),
1265 v_dom,
1266 )?;
1267 ogeom_algo::attach_seam(
1268 model,
1269 &seam,
1270 row_line(v_dom.0)?,
1271 row_line(v_dom.1)?,
1272 surface_id,
1273 ogeom_topo::Location::identity(),
1274 u_dom,
1275 )?;
1276 let wire = ogeom_algo::make_wire(
1277 model,
1278 &[
1279 rail0.clone(),
1280 seam.clone(),
1281 rail1.reversed(),
1282 seam.reversed(),
1283 ],
1284 tol,
1285 )?
1286 .shape;
1287 let face = ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
1288 let mid_u = f64::midpoint(u_dom.0, u_dom.1);
1289 let mid_v = f64::midpoint(v_dom.0, v_dom.1);
1290 let s_mid = surface_geo.point_at(mid_u, mid_v, tol)?;
1291 let (du, dv) = surface_geo.d1_at(mid_u, mid_v, tol)?;
1292 let face = if du.cross(dv).dot(s_mid - outward_hint) >= 0.0 {
1293 face
1294 } else {
1295 face.reversed()
1296 };
1297 Ok((face, rail0, rail1))
1298}
1299
1300fn adopt_border(
1306 model: &mut Model,
1307 edge: &Shape,
1308 surface: &SurfaceGeometry,
1309 slack: f64,
1310 tol: Tolerances,
1311) -> OgeomResult<()> {
1312 let (curve, range) = spine_curve_of(model, edge)?;
1313 let mut off: f64 = 0.0;
1314 for step in 0..=32 {
1315 #[allow(clippy::cast_precision_loss)]
1316 let t = range.0 + (range.1 - range.0) * (step as f64) / 32.0;
1317 let p = curve.point_at(t, tol)?;
1318 off = off.max(ogeom_algo::project_on_surface(surface, p, 16, tol)?.distance);
1319 }
1320 model.widen(edge, ogeom_core::Tolerance::new(off + slack)?)?;
1321 if let Some((a, b)) = ogeom_algo::edge_vertices(model, edge)? {
1322 for v in [&a, &b] {
1323 model.widen(v, ogeom_core::Tolerance::new(off + slack)?)?;
1324 }
1325 }
1326 Ok(())
1327}
1328
1329fn adopted_image(
1336 model: &Model,
1337 edge: &Shape,
1338 surface: &SurfaceGeometry,
1339 tol: Tolerances,
1340) -> OgeomResult<(ogeom_geom::PlanarCurve, (f64, f64), f64)> {
1341 const SAMPLES: u32 = 64;
1342 let (curve, range) = spine_curve_of(model, edge)?;
1343 let mut params = Vec::with_capacity(SAMPLES as usize + 1);
1344 let mut image = Vec::with_capacity(SAMPLES as usize + 1);
1345 let mut guess: Option<(f64, f64)> = None;
1346 for step in 0..=SAMPLES {
1347 let t = range.0 + (range.1 - range.0) * f64::from(step) / f64::from(SAMPLES);
1348 let p = curve.point_at(t, tol)?;
1349 let foot = match guess {
1350 Some(g) => ogeom_algo::project_on_surface_from(surface, p, g, tol)?,
1351 None => ogeom_algo::project_on_surface(surface, p, 16, tol)?,
1352 };
1353 guess = Some(foot.parameters);
1354 params.push(t);
1355 image.push(Point2::new(foot.parameters.0, foot.parameters.1));
1356 }
1357 use ogeom_geom::Surface as _;
1358 let fitted = ogeom_geom::fit::fit_points_2d_at(¶ms, &image, 3, tol.confusion(), tol)?;
1359 let mut off = 0.0_f64;
1360 for step in 0..=SAMPLES * 4 {
1361 let t = range.0 + (range.1 - range.0) * f64::from(step) / f64::from(SAMPLES * 4);
1362 let at = ogeom_geom::Curve2d::point_at(&fitted.curve, t, tol)?;
1363 let ((u0, u1), (v0, v1)) = surface.domain();
1366 let at = Point2::new(at.x.clamp(u0, u1), at.y.clamp(v0, v1));
1367 off = off.max(
1368 surface
1369 .point_at(at.x, at.y, tol)?
1370 .distance(curve.point_at(t, tol)?),
1371 );
1372 }
1373 Ok((fitted.curve.into(), range, off))
1374}
1375
1376#[derive(Debug, Clone, Copy)]
1380enum EndCap {
1381 Plane(Vector),
1383 Skinned,
1386}
1387
1388fn skinned_solid(
1389 model: &mut Model,
1390 skin: &Skin<'_>,
1391 caps: (EndCap, EndCap),
1392 tolerance: f64,
1393 tol: Tolerances,
1394) -> OgeomResult<Built> {
1395 let wall = skinned_wall(model, skin, (None, None), tolerance, tol)?;
1396 let rows = &skin.rows;
1397 let u_dom = wall.u_dom;
1398
1399 let cap = |model: &mut Model,
1400 ring: &Shape,
1401 curve: ogeom_geom::Curve,
1402 outward: Vector|
1403 -> OgeomResult<Shape> {
1404 let at = curve.point_at(u_dom.0, tol)?;
1405 let plane = Plane::through(at, Direction::new(outward, tol)?);
1406 let mut reach = 1.0_f64;
1407 for t in 0..8 {
1408 let p = curve.point_at(u_dom.0 + (u_dom.1 - u_dom.0) * f64::from(t) / 8.0, tol)?;
1409 reach = reach.max(p.distance(at) * 2.0);
1410 }
1411 let cap_surface: SurfaceGeometry =
1412 PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
1413 let walked = walked_about(model, std::slice::from_ref(ring), outward, true, tol)?;
1414 let wire = ogeom_algo::make_wire(model, &walked, tol)?.shape;
1415 let face =
1416 ogeom_algo::make_face(model, cap_surface.clone(), std::slice::from_ref(&wire), tol)?
1417 .shape;
1418 let id = {
1419 let Some(node) = model.node(&face) else {
1420 ogeom_bail!(Dangling, "the cap just built is not in this model");
1421 };
1422 let ogeom_topo::NodeData::Face(data) = node.data() else {
1423 ogeom_bail!(Construction, "the cap holds no face data");
1424 };
1425 data.surface
1426 };
1427 let Some(pcurve) = ogeom_intersect::exact_pcurve_of(&curve, &cap_surface, tol) else {
1428 ogeom_bail!(Construction, "a cap edge has no closed-form pcurve");
1429 };
1430 ogeom_algo::attach_pcurve(
1431 model,
1432 ring,
1433 pcurve,
1434 id,
1435 ogeom_topo::Location::identity(),
1436 u_dom,
1437 )?;
1438 Ok(face)
1439 };
1440 let close = |model: &mut Model,
1441 end: EndCap,
1442 ring: &Shape,
1443 curve: &ogeom_geom::Curve,
1444 row: &[Point],
1445 last: bool| {
1446 match end {
1447 EndCap::Plane(outward) => cap(model, ring, curve.clone(), outward),
1448 EndCap::Skinned => {
1449 let apex = centroid_of(std::slice::from_ref(&row.to_vec()));
1453 let patch = match &skin.traced {
1454 Some(traced) => {
1457 let vs = &traced.sampling.vs;
1458 let at = if last { vs[vs.len() - 1] } else { vs[0] };
1459 Skin::swept(
1460 move |f, s| {
1461 let p = (traced.point)(f, at)?;
1462 Ok(apex + (p - apex) * (1.0 - s * 0.5))
1463 },
1464 traced.sampling.us.clone(),
1465 (0, 2),
1466 skin.round,
1467 )?
1468 }
1469 None => {
1470 let half: Vec<Point> = row
1471 .iter()
1472 .map(|p| Point::from_vector((p.to_vector() + apex.to_vector()) * 0.5))
1473 .collect();
1474 Skin::rows(vec![row.to_vec(), half, vec![apex; row.len()]])
1475 }
1476 };
1477 let next = if last {
1480 &rows[rows.len() - 2]
1481 } else {
1482 &rows[1]
1483 };
1484 let inside = centroid_of(std::slice::from_ref(next));
1485 Ok(apex_patch(model, &patch, Some(ring), Some(inside), tolerance, tol)?.0)
1486 }
1487 }
1488 };
1489 let cap0 = close(model, caps.0, &wall.ring0, &wall.curve0, &rows[0], false)?;
1490 let cap1 = close(
1491 model,
1492 caps.1,
1493 &wall.ring1,
1494 &wall.curve1,
1495 &rows[rows.len() - 1],
1496 true,
1497 )?;
1498
1499 let faces = [wall.face, cap0, cap1];
1500 let sewn = sew(model, &faces, tol)?;
1501 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
1502 ogeom_bail!(Construction, "the skinned solid did not close");
1503 }
1504 make_solid(model, std::slice::from_ref(&sewn.shells[0]))
1505}
1506
1507fn apex_patch(
1516 model: &mut Model,
1517 skin: &Skin<'_>,
1518 shared: Option<&Shape>,
1519 inside: Option<Point>,
1520 tolerance: f64,
1521 tol: Tolerances,
1522) -> OgeomResult<(Shape, Shape)> {
1523 use ogeom_geom::Surface as _;
1524 let rows = &skin.rows;
1525 let fitted = skin.fit(false, tolerance, tol)?;
1526 if !fitted.met {
1527 ogeom_bail!(
1528 NotDone,
1529 "the skin reached {} against a target of {tolerance}",
1530 fitted.error
1531 );
1532 }
1533 let surface = fitted.curve;
1534 let (u_knots, v_knots) = (surface.u_knots().clone(), surface.v_knots().clone());
1535 let (k, l, net) = {
1536 let grid = surface.grid();
1537 let net: Vec<Point> = grid.points().iter().map(|w| (*w).point()).collect();
1538 (grid.u_count(), grid.v_count(), net)
1539 };
1540 let point_at = |i: usize, j: usize| -> Point { net[i * l + j] };
1541 let (u_dom, v_dom) = surface.domain();
1542 let apex = rows[rows.len() - 1][0];
1543
1544 let ring_curve = {
1545 let control: Vec<Point> = (0..k).map(|i| point_at(i, 0)).collect();
1546 ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
1547 u_knots.clone(),
1548 control,
1549 tol,
1550 )?)
1551 };
1552 let seam_curve = {
1553 let control: Vec<Point> = (0..l).map(|j| point_at(0, j)).collect();
1554 ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
1555 v_knots.clone(),
1556 control,
1557 tol,
1558 )?)
1559 };
1560 let surface_geo: SurfaceGeometry = surface.into();
1561 let surface_id = model.geometry_mut().add_surface(surface_geo.clone());
1562
1563 let ring0 = match shared {
1565 Some(edge) => {
1566 adopt_border(
1567 model,
1568 edge,
1569 &surface_geo,
1570 fitted.error + tol.confusion(),
1571 tol,
1572 )?;
1573 edge.clone()
1574 }
1575 None => make_edge(model, ring_curve.clone(), u_dom, tol)?.shape,
1576 };
1577 let anchor0 = ogeom_algo::edge_vertices(model, &ring0)?
1578 .map(|(a, _)| a)
1579 .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "a skinned ring has no vertex"))?;
1580 let apex_vertex = model.add_vertex(VertexData::new(apex));
1581 let apex_edge = {
1582 let mut data = EdgeData::new();
1583 data.degenerate = true;
1584 model.add_edge(data, &[apex_vertex.clone(), apex_vertex.clone()])?
1585 };
1586 let seam = make_edge_between(model, seam_curve, v_dom, &anchor0, &apex_vertex, tol)?.shape;
1587
1588 let row_line = |v: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1589 Ok(Line2d::over(
1590 ogeom_math::Axis2::new(Point2::new(0.0, v), ogeom_math::Direction2::X),
1591 u_dom.0 - 1.0,
1592 u_dom.1 + 1.0,
1593 )?
1594 .into())
1595 };
1596 let column_line = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1597 Ok(Line2d::over(
1598 ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
1599 v_dom.0 - 1.0,
1600 v_dom.1 + 1.0,
1601 )?
1602 .into())
1603 };
1604 ogeom_algo::attach_pcurve(
1605 model,
1606 &ring0,
1607 row_line(v_dom.0)?,
1608 surface_id,
1609 ogeom_topo::Location::identity(),
1610 u_dom,
1611 )?;
1612 ogeom_algo::attach_pcurve(
1616 model,
1617 &apex_edge,
1618 row_line(v_dom.1)?,
1619 surface_id,
1620 ogeom_topo::Location::identity(),
1621 u_dom,
1622 )?;
1623 ogeom_algo::attach_seam(
1624 model,
1625 &seam,
1626 column_line(u_dom.0)?,
1627 column_line(u_dom.1)?,
1628 surface_id,
1629 ogeom_topo::Location::identity(),
1630 v_dom,
1631 )?;
1632
1633 let wire = ogeom_algo::make_wire(
1634 model,
1635 &[
1636 ring0.clone(),
1637 seam.clone(),
1638 apex_edge.reversed(),
1639 seam.reversed(),
1640 ],
1641 tol,
1642 )?
1643 .shape;
1644 let face = ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
1645 let mid_u = f64::midpoint(u_dom.0, u_dom.1);
1646 let mid_v = f64::midpoint(v_dom.0, v_dom.1);
1647 let s_mid = surface_geo.point_at(mid_u, mid_v, tol)?;
1648 let (du, dv) = surface_geo.d1_at(mid_u, mid_v, tol)?;
1649 let local = match inside {
1655 Some(at) => at,
1656 None => {
1657 let mut sum = Vector::ZERO;
1658 for k in 0..16 {
1659 let u = u_dom.0 + (u_dom.1 - u_dom.0) * f64::from(k) / 16.0;
1660 sum += surface_geo.point_at(u, mid_v, tol)?.to_vector();
1661 }
1662 Point::from_vector(sum / 16.0)
1663 }
1664 };
1665 let face = if du.cross(dv).dot(s_mid - local) >= 0.0 {
1666 face
1667 } else {
1668 face.reversed()
1669 };
1670 Ok((face, ring0))
1671}
1672
1673fn centroid_of(rows: &[Vec<Point>]) -> Point {
1675 let mut c = Vector::new(0.0, 0.0, 0.0);
1676 let mut n = 0.0;
1677 for row in rows {
1678 for p in row {
1679 c += p.to_vector();
1680 n += 1.0;
1681 }
1682 }
1683 Point::from_vector(c / n)
1684}
1685
1686fn skinned_solid_to_apex(
1689 model: &mut Model,
1690 skin: &Skin<'_>,
1691 cap_outward: Vector,
1692 tolerance: f64,
1693 tol: Tolerances,
1694) -> OgeomResult<Built> {
1695 use ogeom_geom::Curve3d as _;
1696 let (wall, ring0) = apex_patch(model, skin, None, None, tolerance, tol)?;
1697 let (ring_curve, u_dom) = {
1698 let (curve, range) = spine_curve_of(model, &ring0)?;
1699 (curve, range)
1700 };
1701
1702 let cap = {
1705 let at = ring_curve.point_at(u_dom.0, tol)?;
1706 let plane = Plane::through(at, Direction::new(cap_outward, tol)?);
1707 let mut reach = 1.0_f64;
1708 for t in 0..8 {
1709 let p = ring_curve.point_at(u_dom.0 + (u_dom.1 - u_dom.0) * f64::from(t) / 8.0, tol)?;
1710 reach = reach.max(p.distance(at) * 2.0);
1711 }
1712 let cap_surface: SurfaceGeometry =
1713 PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
1714 let walked = walked_about(model, std::slice::from_ref(&ring0), cap_outward, true, tol)?;
1715 let wire = ogeom_algo::make_wire(model, &walked, tol)?.shape;
1716 let face =
1717 ogeom_algo::make_face(model, cap_surface.clone(), std::slice::from_ref(&wire), tol)?
1718 .shape;
1719 let id = {
1720 let Some(node) = model.node(&face) else {
1721 ogeom_bail!(Dangling, "the cap just built is not in this model");
1722 };
1723 let ogeom_topo::NodeData::Face(data) = node.data() else {
1724 ogeom_bail!(Construction, "the cap holds no face data");
1725 };
1726 data.surface
1727 };
1728 let Some(pcurve) = ogeom_intersect::exact_pcurve_of(&ring_curve, &cap_surface, tol) else {
1729 ogeom_bail!(Construction, "a cap edge has no closed-form pcurve");
1730 };
1731 ogeom_algo::attach_pcurve(
1732 model,
1733 &ring0,
1734 pcurve,
1735 id,
1736 ogeom_topo::Location::identity(),
1737 u_dom,
1738 )?;
1739 face
1740 };
1741
1742 let faces = [wall, cap];
1743 let sewn = sew(model, &faces, tol)?;
1744 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
1745 ogeom_bail!(Construction, "the skinned apex solid did not close");
1746 }
1747 make_solid(model, std::slice::from_ref(&sewn.shells[0]))
1748}
1749
1750fn closed_skinned_solid(
1758 model: &mut Model,
1759 skin: &Skin<'_>,
1760 tolerance: f64,
1761 tol: Tolerances,
1762) -> OgeomResult<Built> {
1763 let shell = closed_skinned_shell(model, skin, tolerance, tol)?;
1764 make_solid(model, std::slice::from_ref(&shell))
1765}
1766
1767fn closed_skinned_shell(
1770 model: &mut Model,
1771 skin: &Skin<'_>,
1772 tolerance: f64,
1773 tol: Tolerances,
1774) -> OgeomResult<Shape> {
1775 use ogeom_geom::Surface as _;
1776 let rows = &skin.rows;
1777 let fitted = skin.fit(true, tolerance, tol)?;
1778 if !fitted.met {
1779 ogeom_bail!(
1780 NotDone,
1781 "the closed skin reached {} against a target of {tolerance}",
1782 fitted.error
1783 );
1784 }
1785 let surface = fitted.curve;
1786 let (u_knots, v_knots) = (surface.u_knots().clone(), surface.v_knots().clone());
1787 let (k, l, net) = {
1788 let grid = surface.grid();
1789 let net: Vec<Point> = grid.points().iter().map(|w| (*w).point()).collect();
1790 (grid.u_count(), grid.v_count(), net)
1791 };
1792 let point_at = |i: usize, j: usize| -> Point { net[i * l + j] };
1793 let (u_dom, v_dom) = surface.domain();
1794
1795 let along_u = {
1798 let control: Vec<Point> = (0..k).map(|i| point_at(i, 0)).collect();
1799 ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(u_knots, control, tol)?)
1800 };
1801 let along_v = {
1802 let control: Vec<Point> = (0..l).map(|j| point_at(0, j)).collect();
1803 ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(v_knots, control, tol)?)
1804 };
1805 let surface_geo: SurfaceGeometry = surface.into();
1806 let surface_id = model.geometry_mut().add_surface(surface_geo.clone());
1807
1808 let u_edge = make_edge(model, along_u, u_dom, tol)?.shape;
1809 let Some((corner, _)) = ogeom_algo::edge_vertices(model, &u_edge)? else {
1810 ogeom_bail!(Construction, "the closed skin's seam has no vertex");
1811 };
1812 let v_edge = make_edge_between(model, along_v, v_dom, &corner, &corner, tol)?.shape;
1813
1814 let row_line = |v: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1815 Ok(Line2d::over(
1816 ogeom_math::Axis2::new(Point2::new(0.0, v), ogeom_math::Direction2::X),
1817 u_dom.0 - 1.0,
1818 u_dom.1 + 1.0,
1819 )?
1820 .into())
1821 };
1822 let column_line = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1823 Ok(Line2d::over(
1824 ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
1825 v_dom.0 - 1.0,
1826 v_dom.1 + 1.0,
1827 )?
1828 .into())
1829 };
1830 ogeom_algo::attach_seam(
1833 model,
1834 &u_edge,
1835 row_line(v_dom.0)?,
1836 row_line(v_dom.1)?,
1837 surface_id,
1838 ogeom_topo::Location::identity(),
1839 u_dom,
1840 )?;
1841 ogeom_algo::attach_seam(
1842 model,
1843 &v_edge,
1844 column_line(u_dom.1)?,
1845 column_line(u_dom.0)?,
1846 surface_id,
1847 ogeom_topo::Location::identity(),
1848 v_dom,
1849 )?;
1850
1851 let wire = ogeom_algo::make_wire(
1852 model,
1853 &[
1854 u_edge.clone(),
1855 v_edge.clone(),
1856 u_edge.reversed(),
1857 v_edge.reversed(),
1858 ],
1859 tol,
1860 )?
1861 .shape;
1862 let face = ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
1863 let mid_u = f64::midpoint(u_dom.0, u_dom.1);
1866 let mid_v = f64::midpoint(v_dom.0, v_dom.1);
1867 let s_mid = surface_geo.point_at(mid_u, mid_v, tol)?;
1868 let (du, dv) = surface_geo.d1_at(mid_u, mid_v, tol)?;
1869 let face = if du.cross(dv).dot(s_mid - section_centre(rows, s_mid)) >= 0.0 {
1870 face
1871 } else {
1872 face.reversed()
1873 };
1874
1875 let sewn = sew(model, std::slice::from_ref(&face), tol)?;
1876 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
1877 ogeom_bail!(Construction, "the closed skin did not close");
1878 }
1879 Ok(sewn.shells[0].clone())
1880}
1881
1882fn loft_to_point(
1888 model: &mut Model,
1889 section: &Shape,
1890 apex: &Shape,
1891 tol: Tolerances,
1892) -> OgeomResult<Built> {
1893 if !ogeom_algo::is_wire_closed(model, section, tol)? {
1894 ogeom_bail!(Construction, "a loft section must be closed");
1895 }
1896 let apex_point = {
1897 let Some(data) = model.node(apex).and_then(|n| n.data().as_vertex()) else {
1898 ogeom_bail!(Construction, "the apex vertex holds no data");
1899 };
1900 data.point
1901 };
1902
1903 let edges = explore(model, section, Filter::OfType(ShapeType::Edge))?;
1905 if edges.len() == 1
1906 && let Some(data) = model.node(&edges[0]).and_then(|n| n.data().as_edge())
1907 && let Some(EdgeRepr::Curve3d { curve, .. }) = data.curve3d()
1908 && let Some(Curve::Circle(c)) = model.geometry().curve(*curve)
1909 {
1910 let circle = c.circle();
1911 let axis = circle.frame().z().vector();
1912 let rise = apex_point - circle.centre();
1913 let height = rise.dot(axis);
1914 if rise.cross(axis).magnitude() > tol.confusion() * 10.0 {
1915 ogeom_bail!(
1916 Construction,
1917 "a circle lofts to a point on its own axis; the oblique cone \
1918 needs the skinned machinery; see docs/PARITY.md, offset.loft"
1919 );
1920 }
1921 if height.abs() <= tol.confusion() {
1922 ogeom_bail!(Construction, "the apex sits in the section's own plane");
1923 }
1924 let base = if height > 0.0 {
1925 circle.frame()
1926 } else {
1927 Frame::new(
1928 circle.centre(),
1929 -circle.frame().z(),
1930 circle.frame().x(),
1931 tol,
1932 )?
1933 };
1934 let mut built =
1935 ogeom_algo::make_cone(model, base, circle.radius(), 0.0, height.abs(), tol)?;
1936 built.history.generate(section, built.shape.clone());
1937 built.history.generate(apex, built.shape.clone());
1938 return Ok(built);
1939 }
1940
1941 let mut corners: Vec<Point> = Vec::new();
1943 for edge in model.ordered_children_of(section)? {
1944 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
1945 ogeom_bail!(Construction, "a section edge holds no data");
1946 };
1947 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1948 ogeom_bail!(Construction, "a section edge has no curve");
1949 };
1950 let Some(Curve::Line(line)) = model.geometry().curve(*curve).cloned() else {
1951 ogeom_bail!(
1952 Construction,
1953 "a mixed or curved section lofts to a point through the \
1954 skinned machinery; see docs/PARITY.md, offset.loft"
1955 );
1956 };
1957 let t = if edge.orientation() == ogeom_topo::Orientation::Reversed {
1958 range.1
1959 } else {
1960 range.0
1961 };
1962 corners.push(ogeom_geom::Curve::Line(line).point_at(t, tol)?);
1963 }
1964 if corners.len() < 3 {
1965 ogeom_bail!(Construction, "a pyramid needs at least three base corners");
1966 }
1967 let apex_vertex = ogeom_algo::make_vertex(model, apex_point).shape;
1968 let base_vertices: Vec<Shape> = corners
1969 .iter()
1970 .map(|p| ogeom_algo::make_vertex(model, *p).shape)
1971 .collect();
1972 let segment =
1973 |model: &mut Model, from: (&Shape, Point), to: (&Shape, Point)| -> OgeomResult<Shape> {
1974 let line = ogeom_geom::LineCurve::segment(from.1, to.1, tol)?;
1975 let curve: Curve = line.into();
1976 let domain = curve.domain();
1977 Ok(make_edge_between(model, curve, domain, from.0, to.0, tol)?.shape)
1978 };
1979 let count = corners.len();
1980 let mut base_edges = Vec::with_capacity(count);
1981 let mut rails = Vec::with_capacity(count);
1982 for i in 0..count {
1983 let next = (i + 1) % count;
1984 base_edges.push(segment(
1985 model,
1986 (&base_vertices[i], corners[i]),
1987 (&base_vertices[next], corners[next]),
1988 )?);
1989 rails.push(segment(
1990 model,
1991 (&base_vertices[i], corners[i]),
1992 (&apex_vertex, apex_point),
1993 )?);
1994 }
1995 let centroid = {
1996 let mut c = Vector::new(0.0, 0.0, 0.0);
1997 for p in &corners {
1998 c += p.to_vector();
1999 }
2000 #[allow(clippy::cast_precision_loss)]
2001 Point::from_vector(c / count as f64 / 4.0 * 3.0 + apex_point.to_vector() / 4.0)
2002 };
2003 let planar = |model: &mut Model, pts: [Point; 3], walk: Vec<Shape>| -> OgeomResult<Shape> {
2004 let n = (pts[1] - pts[0]).cross(pts[2] - pts[0]);
2005 let m = n.magnitude();
2006 if m <= tol.confusion() {
2007 ogeom_bail!(Construction, "a wall of the pyramid is degenerate");
2008 }
2009 let mut outward = n / m;
2010 if outward.dot(pts[0] - centroid) < 0.0 {
2011 outward = -outward;
2012 }
2013 let plane = ogeom_math::Plane::through(pts[0], Direction::new(outward, tol)?);
2014 let mut reach = 1.0_f64;
2015 for p in pts {
2016 reach = reach.max(p.distance(pts[0]) * 2.0);
2017 }
2018 let surface: SurfaceGeometry =
2019 PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
2020 let id = model.geometry_mut().add_surface(surface.clone());
2021 let signed = {
2022 let (du, dv) = {
2023 use ogeom_geom::Surface as _;
2024 surface.d1_at(0.0, 0.0, tol)?
2025 };
2026 du.cross(dv).dot(outward) >= 0.0
2027 };
2028 let mut wired = Vec::with_capacity(walk.len());
2029 for used in &walk {
2030 let (curve, range) = spine_curve_of(model, used)?;
2031 let Some(pcurve) = ogeom_intersect::exact_pcurve_of(&curve, &surface, tol) else {
2032 ogeom_bail!(Construction, "a wall edge has no closed-form pcurve");
2033 };
2034 ogeom_algo::attach_pcurve(
2035 model,
2036 used,
2037 pcurve,
2038 id,
2039 ogeom_topo::Location::identity(),
2040 range,
2041 )?;
2042 wired.push(used.clone());
2043 }
2044 let wire = ogeom_algo::make_wire(model, &wired, tol)?.shape;
2045 let face = ogeom_algo::make_face_on(model, id, std::slice::from_ref(&wire), tol)?.shape;
2046 Ok(if signed { face } else { face.reversed() })
2047 };
2048 let mut faces = Vec::with_capacity(count + 1);
2049 for i in 0..count {
2050 let next = (i + 1) % count;
2051 faces.push(planar(
2052 model,
2053 [corners[i], corners[next], apex_point],
2054 vec![
2055 base_edges[i].clone(),
2056 rails[next].clone(),
2057 rails[i].reversed(),
2058 ],
2059 )?);
2060 }
2061 let base_walk: Vec<Shape> = (0..count).rev().map(|i| base_edges[i].reversed()).collect();
2063 faces.push({
2064 let n = (corners[1] - corners[0]).cross(corners[2] - corners[0]);
2065 let mut outward = n / n.magnitude();
2066 if outward.dot(corners[0] - centroid) < 0.0 {
2067 outward = -outward;
2068 }
2069 let plane = ogeom_math::Plane::through(corners[0], Direction::new(outward, tol)?);
2070 let mut reach = 1.0_f64;
2071 for p in &corners {
2072 reach = reach.max(p.distance(corners[0]) * 2.0);
2073 }
2074 let surface: SurfaceGeometry =
2075 PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
2076 let id = model.geometry_mut().add_surface(surface.clone());
2077 for used in &base_walk {
2078 let (curve, range) = spine_curve_of(model, used)?;
2079 let Some(pcurve) = ogeom_intersect::exact_pcurve_of(&curve, &surface, tol) else {
2080 ogeom_bail!(Construction, "a base edge has no closed-form pcurve");
2081 };
2082 ogeom_algo::attach_pcurve(
2083 model,
2084 used,
2085 pcurve,
2086 id,
2087 ogeom_topo::Location::identity(),
2088 range,
2089 )?;
2090 }
2091 let wire = ogeom_algo::make_wire(model, &base_walk, tol)?.shape;
2092 let face = ogeom_algo::make_face_on(model, id, std::slice::from_ref(&wire), tol)?.shape;
2093 let signed = {
2094 use ogeom_geom::Surface as _;
2095 let (du, dv) = surface.d1_at(0.0, 0.0, tol)?;
2096 du.cross(dv).dot(outward) >= 0.0
2097 };
2098 if signed { face } else { face.reversed() }
2099 });
2100
2101 let sewn = sew(model, &faces, tol)?;
2102 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
2103 ogeom_bail!(Construction, "the pyramid did not close");
2104 }
2105 let mut built = make_solid(model, std::slice::from_ref(&sewn.shells[0]))?;
2106 built.history.generate(section, built.shape.clone());
2107 built.history.generate(apex, built.shape.clone());
2108 Ok(built)
2109}
2110
2111pub fn make_loft_skinned_aligned(
2123 model: &mut Model,
2124 sections: &[Shape],
2125 hints: &[Point],
2126 tolerance: f64,
2127 tol: Tolerances,
2128) -> OgeomResult<Built> {
2129 if hints.len() != sections.len() {
2130 ogeom_bail!(
2131 Construction,
2132 "{} hints against {} sections; each section names its own start",
2133 hints.len(),
2134 sections.len()
2135 );
2136 }
2137 if sections.len() < 2 {
2138 ogeom_bail!(Construction, "a loft needs at least two sections");
2139 }
2140 let mut loops: Vec<Section> = Vec::with_capacity(sections.len());
2141 let mut planes: Vec<Plane> = Vec::with_capacity(sections.len());
2142 for (wire, hint) in sections.iter().zip(hints) {
2143 if model.kind_of(wire)? != ShapeType::Wire {
2144 ogeom_bail!(Construction, "a loft section is a closed wire");
2145 }
2146 if !ogeom_algo::is_wire_closed(model, wire, tol)? {
2147 ogeom_bail!(Construction, "a loft section must be closed");
2148 }
2149 let Some(plane) = ogeom_algo::find_plane(model, wire, tol)? else {
2150 ogeom_bail!(Construction, "a loft section must be planar");
2151 };
2152 planes.push(plane);
2153 loops.push(Section::Loop(section_loop(model, wire, Some(*hint), tol)?));
2154 }
2155 let skin = Skin::sections(loops, false)?;
2156 let rows = &skin.rows;
2157 let outward0 = {
2158 let towards = rows[1][0] - rows[0][0];
2159 let n = planes[0].normal().vector();
2160 if n.dot(towards) > 0.0 { -n } else { n }
2161 };
2162 let outward1 = {
2163 let towards = rows[rows.len() - 2][0] - rows[rows.len() - 1][0];
2164 let n = planes[planes.len() - 1].normal().vector();
2165 if n.dot(towards) > 0.0 { -n } else { n }
2166 };
2167 let mut built = skinned_solid(
2168 model,
2169 &skin,
2170 (EndCap::Plane(outward0), EndCap::Plane(outward1)),
2171 tolerance,
2172 tol,
2173 )?;
2174 for section in sections {
2175 built.history.generate(section, built.shape.clone());
2176 }
2177 Ok(built)
2178}
2179
2180pub fn make_loft_skinned_closed(
2199 model: &mut Model,
2200 sections: &[Shape],
2201 tolerance: f64,
2202 tol: Tolerances,
2203) -> OgeomResult<Built> {
2204 if sections.len() < 3 {
2205 ogeom_bail!(Construction, "a closed loft needs at least three sections");
2206 }
2207 let mut loops: Vec<Section> = Vec::with_capacity(sections.len());
2208 for wire in sections {
2209 if model.kind_of(wire)? != ShapeType::Wire {
2210 ogeom_bail!(Construction, "a loft section is a closed wire");
2211 }
2212 if !ogeom_algo::is_wire_closed(model, wire, tol)? {
2213 ogeom_bail!(Construction, "a loft section must be closed");
2214 }
2215 if ogeom_algo::find_plane(model, wire, tol)?.is_none() {
2216 ogeom_bail!(Construction, "a loft section must be planar");
2217 }
2218 loops.push(Section::Loop(section_loop(model, wire, None, tol)?));
2219 }
2220 let skin = Skin::sections(loops, true)?;
2221 let mut built = closed_skinned_solid(model, &skin, tolerance, tol)?;
2222 for section in sections {
2223 built.history.generate(section, built.shape.clone());
2224 }
2225 Ok(built)
2226}
2227
2228struct SkinnedStrip {
2235 face: Shape,
2236 bottom: Shape,
2238 top: Shape,
2240 rail0: Shape,
2242 rail1: Shape,
2244}
2245
2246#[allow(clippy::too_many_arguments, reason = "one strip, spelled out")]
2253fn skinned_strip(
2254 model: &mut Model,
2255 skin: &Skin<'_>,
2256 corners: (&Shape, &Shape, &Shape, &Shape),
2257 shared: [Option<&Shape>; 4],
2258 outward_hint: Point,
2259 hole: bool,
2260 tolerance: f64,
2261 tol: Tolerances,
2262) -> OgeomResult<SkinnedStrip> {
2263 use ogeom_geom::Surface as _;
2264 let rows = &skin.rows;
2265 if let Some(plane) = plane_of_rows(rows, tol) {
2270 return planar_strip(
2271 model,
2272 skin,
2273 plane,
2274 corners,
2275 shared,
2276 outward_hint,
2277 hole,
2278 tolerance,
2279 tol,
2280 );
2281 }
2282 let fitted = skin.fit(false, tolerance, tol)?;
2286 if !fitted.met {
2287 ogeom_bail!(
2288 NotDone,
2289 "the strip reached {} against a target of {tolerance}",
2290 fitted.error
2291 );
2292 }
2293 let error = fitted.error.max(tol.confusion());
2294 let surface = fitted.curve;
2295 let (u_knots, v_knots) = (surface.u_knots().clone(), surface.v_knots().clone());
2296 let (k, l, net) = {
2297 let grid = surface.grid();
2298 let net: Vec<Point> = grid.points().iter().map(|w| (*w).point()).collect();
2299 (grid.u_count(), grid.v_count(), net)
2300 };
2301 let point_at = |i: usize, j: usize| -> Point { net[i * l + j] };
2302 let (u_dom, v_dom) = surface.domain();
2303
2304 let u_curve = |j: usize| -> OgeomResult<ogeom_geom::Curve> {
2305 let control: Vec<Point> = (0..k).map(|i| point_at(i, j)).collect();
2306 Ok(ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
2307 u_knots.clone(),
2308 control,
2309 tol,
2310 )?))
2311 };
2312 let v_curve = |i: usize| -> OgeomResult<ogeom_geom::Curve> {
2313 let control: Vec<Point> = (0..l).map(|j| point_at(i, j)).collect();
2314 Ok(ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
2315 v_knots.clone(),
2316 control,
2317 tol,
2318 )?))
2319 };
2320 let surface_geo: SurfaceGeometry = surface.into();
2321 let surface_id = model.geometry_mut().add_surface(surface_geo.clone());
2322
2323 let (c00, c10, c01, c11) = corners;
2324 let border = |model: &mut Model,
2327 given: Option<&Shape>,
2328 curve: ogeom_geom::Curve,
2329 range: (f64, f64),
2330 from: &Shape,
2331 to: &Shape|
2332 -> OgeomResult<Shape> {
2333 match given {
2334 Some(edge) => {
2335 adopt_border(model, edge, &surface_geo, error + tol.confusion(), tol)?;
2336 Ok(edge.clone())
2337 }
2338 None => Ok(make_edge_between(model, curve, range, from, to, tol)?.shape),
2339 }
2340 };
2341 let bottom = border(model, shared[0], u_curve(0)?, u_dom, c00, c10)?;
2342 let top = border(model, shared[1], u_curve(l - 1)?, u_dom, c01, c11)?;
2343 let rail0 = border(model, shared[2], v_curve(0)?, v_dom, c00, c01)?;
2344 let rail1 = border(model, shared[3], v_curve(k - 1)?, v_dom, c10, c11)?;
2345
2346 let row_line = |v: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
2347 Ok(Line2d::over(
2348 ogeom_math::Axis2::new(Point2::new(0.0, v), ogeom_math::Direction2::X),
2349 u_dom.0 - 1.0,
2350 u_dom.1 + 1.0,
2351 )?
2352 .into())
2353 };
2354 let column_line = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
2355 Ok(Line2d::over(
2356 ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
2357 v_dom.0 - 1.0,
2358 v_dom.1 + 1.0,
2359 )?
2360 .into())
2361 };
2362 for (edge, given, straight, span) in [
2367 (&bottom, shared[0].is_some(), row_line(v_dom.0)?, u_dom),
2368 (&top, shared[1].is_some(), row_line(v_dom.1)?, u_dom),
2369 (&rail0, shared[2].is_some(), column_line(u_dom.0)?, v_dom),
2370 (&rail1, shared[3].is_some(), column_line(u_dom.1)?, v_dom),
2371 ] {
2372 let (image, range) = if given {
2373 let (image, range, off) = adopted_image(model, edge, &surface_geo, tol)?;
2374 if off > tol.confusion() {
2377 let held = ogeom_core::Tolerance::new(off)?;
2378 model.widen(edge, held)?;
2379 if let Some((a, b)) = ogeom_algo::edge_vertices(model, edge)? {
2380 for v in [&a, &b] {
2381 model.widen(v, held)?;
2382 }
2383 }
2384 }
2385 (image, range)
2386 } else {
2387 (straight, span)
2388 };
2389 ogeom_algo::attach_pcurve(
2390 model,
2391 edge,
2392 image,
2393 surface_id,
2394 ogeom_topo::Location::identity(),
2395 range,
2396 )?;
2397 }
2398 for edge in [&bottom, &top, &rail0, &rail1] {
2402 model.widen(edge, ogeom_core::Tolerance::new(error)?)?;
2403 }
2404
2405 let wire = ogeom_algo::make_wire(
2406 model,
2407 &[
2408 bottom.clone(),
2409 rail1.clone(),
2410 top.reversed(),
2411 rail0.reversed(),
2412 ],
2413 tol,
2414 )?
2415 .shape;
2416 let face = ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
2417 let mid_u = f64::midpoint(u_dom.0, u_dom.1);
2418 let mid_v = f64::midpoint(v_dom.0, v_dom.1);
2419 let s_mid = surface_geo.point_at(mid_u, mid_v, tol)?;
2420 let (du, dv) = surface_geo.d1_at(mid_u, mid_v, tol)?;
2421 let natural_out = du.cross(dv).dot(s_mid - outward_hint) >= 0.0;
2422 let face = if natural_out == !hole {
2423 face
2424 } else {
2425 face.reversed()
2426 };
2427 Ok(SkinnedStrip {
2428 face,
2429 bottom,
2430 top,
2431 rail0,
2432 rail1,
2433 })
2434}
2435
2436fn plane_of_rows(rows: &[Vec<Point>], tol: Tolerances) -> Option<Plane> {
2438 let first = rows.first()?;
2439 let last = rows.last()?;
2440 let origin = *first.first()?;
2441 let across = *first.last()? - origin;
2442 let along = *last.first()? - origin;
2443 let normal = across.cross(along);
2444 if normal.magnitude() <= tol.confusion() * across.magnitude().max(along.magnitude()) {
2445 return None;
2446 }
2447 let normal = Direction::new(normal, tol).ok()?;
2448 let plane = Plane::through(origin, normal);
2449 rows.iter()
2450 .flatten()
2451 .all(|p| plane.distance_to(*p) <= tol.confusion())
2452 .then_some(plane)
2453}
2454
2455#[derive(Clone, Copy)]
2457enum Side {
2458 Row(usize),
2459 Column(usize),
2460}
2461
2462#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
2465fn planar_strip(
2466 model: &mut Model,
2467 skin: &Skin<'_>,
2468 plane: Plane,
2469 corners: (&Shape, &Shape, &Shape, &Shape),
2470 shared: [Option<&Shape>; 4],
2471 outward_hint: Point,
2472 hole: bool,
2473 tolerance: f64,
2474 tol: Tolerances,
2475) -> OgeomResult<SkinnedStrip> {
2476 let rows = &skin.rows;
2477 let through = |points: &[Point], side: Side| -> OgeomResult<ogeom_geom::Curve> {
2482 let fitted = match (&skin.traced, side) {
2483 (Some(traced), Side::Row(j)) => {
2484 let v = traced.sampling.vs[j];
2485 ogeom_geom::fit::fit_curve_sampled(
2486 |u| (traced.point)(u, v),
2487 &traced.sampling.us,
2488 false,
2489 3,
2490 tolerance * 0.5,
2491 tol,
2492 )?
2493 }
2494 (Some(traced), Side::Column(i)) if traced.sampling.between.1 => {
2495 let u = traced.sampling.us[i];
2496 ogeom_geom::fit::fit_curve_sampled(
2497 |v| (traced.point)(u, v),
2498 &traced.sampling.vs,
2499 false,
2500 3,
2501 tolerance * 0.5,
2502 tol,
2503 )?
2504 }
2505 _ => ogeom_geom::fit::fit_points(points, 3, tolerance * 0.5, tol)?,
2506 };
2507 if !fitted.met {
2508 ogeom_bail!(
2509 NotDone,
2510 "a planar strip's border reached {} against a target of {tolerance}",
2511 fitted.error
2512 );
2513 }
2514 Ok(ogeom_geom::Curve::BSpline(fitted.curve))
2515 };
2516 let column = |i: usize| -> Vec<Point> { rows.iter().map(|row| row[i]).collect() };
2517 let last = rows[0].len() - 1;
2518 let (c00, c10, c01, c11) = corners;
2519 let border = |model: &mut Model,
2520 given: Option<&Shape>,
2521 points: Vec<Point>,
2522 side: Side,
2523 from: &Shape,
2524 to: &Shape|
2525 -> OgeomResult<Shape> {
2526 if let Some(edge) = given {
2527 return Ok(edge.clone());
2528 }
2529 let curve = through(&points, side)?;
2530 let domain = curve.domain();
2531 Ok(make_edge_between(model, curve, domain, from, to, tol)?.shape)
2532 };
2533 let bottom = border(model, shared[0], rows[0].clone(), Side::Row(0), c00, c10)?;
2534 let top_row = rows.len() - 1;
2535 let top = border(
2536 model,
2537 shared[1],
2538 rows[top_row].clone(),
2539 Side::Row(top_row),
2540 c01,
2541 c11,
2542 )?;
2543 let rail0 = border(model, shared[2], column(0), Side::Column(0), c00, c01)?;
2544 let rail1 = border(model, shared[3], column(last), Side::Column(last), c10, c11)?;
2545
2546 let border: Vec<Point> = rows[0]
2551 .iter()
2552 .copied()
2553 .chain(rows.iter().skip(1).map(|row| row[last]))
2554 .chain(rows[top_row].iter().rev().skip(1).copied())
2555 .chain(
2556 rows.iter()
2557 .rev()
2558 .skip(1)
2559 .take(top_row.saturating_sub(1))
2560 .map(|row| row[0]),
2561 )
2562 .collect();
2563 let turn = (0..border.len())
2564 .map(|i| (border[i] - border[0]).cross(border[(i + 1) % border.len()] - border[0]))
2565 .fold(Vector::new(0.0, 0.0, 0.0), |sum, v| sum + v);
2566 let normal = if turn.dot(plane.normal().vector()) >= 0.0 {
2567 plane.normal()
2568 } else {
2569 plane.normal().reversed()
2570 };
2571 let wound = Plane::through(plane.origin(), normal);
2572 let reach = rows
2573 .iter()
2574 .flatten()
2575 .map(|p| p.distance(plane.origin()))
2576 .fold(1.0_f64, f64::max)
2577 * 2.0;
2578 let surface: SurfaceGeometry =
2579 PlaneSurface::over(wound, (-reach, reach), (-reach, reach))?.into();
2580 let face = ogeom_algo::make_face_with_pcurves(
2581 model,
2582 surface.clone(),
2583 &[vec![
2584 bottom.clone(),
2585 rail1.clone(),
2586 top.reversed(),
2587 rail0.reversed(),
2588 ]],
2589 tol,
2590 )?
2591 .shape;
2592 let mid = rows[rows.len() / 2][last / 2];
2593 let natural_out = normal.vector().dot(mid - outward_hint) >= 0.0;
2594 let face = if natural_out == !hole {
2595 face
2596 } else {
2597 face.reversed()
2598 };
2599 Ok(SkinnedStrip {
2600 face,
2601 bottom,
2602 top,
2603 rail0,
2604 rail1,
2605 })
2606}
2607
2608fn coaxial_circles_loft(
2613 model: &mut Model,
2614 sections: &[Shape],
2615 tol: Tolerances,
2616) -> OgeomResult<Option<Built>> {
2617 let mut circles = Vec::with_capacity(sections.len());
2618 for wire in sections {
2619 if model.kind_of(wire)? != ShapeType::Wire {
2620 return Ok(None);
2621 }
2622 let edges = model.ordered_children_of(wire)?;
2623 let [edge] = edges.as_slice() else {
2624 return Ok(None);
2625 };
2626 let (curve, _) = spine_curve_of(model, edge)?;
2627 let ogeom_geom::Curve::Circle(c) = curve else {
2628 return Ok(None);
2629 };
2630 let placed = c
2631 .circle()
2632 .transformed(&edge.transform(model.datums())?, tol)?;
2633 circles.push(placed);
2634 }
2635 let first = circles[0].frame();
2636 let (c0, z0) = (first.origin(), first.z().vector());
2637 let last = circles[circles.len() - 1].centre();
2638 let rise = last - c0;
2639 if rise.magnitude() <= tol.confusion() {
2640 return Ok(None);
2641 }
2642 let z = rise / rise.magnitude();
2643 if z.cross(z0).magnitude() > tol.angular() {
2644 return Ok(None);
2645 }
2646 let mut heights = Vec::with_capacity(circles.len());
2647 for c in &circles {
2648 let off = c.centre() - c0;
2649 if off.cross(z).magnitude() > tol.confusion() * 10.0
2650 || c.frame().z().vector().cross(z).magnitude() > tol.angular()
2651 {
2652 return Ok(None);
2653 }
2654 heights.push(off.dot(z));
2655 }
2656 if heights.windows(2).any(|w| w[1] <= w[0] + tol.confusion()) {
2657 return Ok(None);
2658 }
2659 let x = first.x().vector();
2660 let meridian: Vec<Point> = circles
2661 .iter()
2662 .zip(&heights)
2663 .map(|(c, h)| c0 + z * *h + x * c.radius())
2664 .collect();
2665 let degree = (meridian.len() - 1).min(3);
2666 let fitted = ogeom_geom::fit::fit_points(&meridian, degree, tol.confusion() * 1e-3, tol)?;
2667 let spline: ogeom_geom::Curve = fitted.curve.into();
2668 let domain = spline.domain();
2669 let top = c0 + z * heights[heights.len() - 1];
2670 let vertex = |model: &mut Model, p: Point| ogeom_algo::make_vertex(model, p).shape;
2671 let (v_axis0, v_axis1) = (vertex(model, c0), vertex(model, top));
2672 let (v_rim0, v_rim1) = (
2673 vertex(model, meridian[0]),
2674 vertex(model, meridian[meridian.len() - 1]),
2675 );
2676 let segment =
2677 |model: &mut Model, a: (&Shape, Point), b: (&Shape, Point)| -> OgeomResult<Shape> {
2678 let line: ogeom_geom::Curve = LineCurve::segment(a.1, b.1, tol)?.into();
2679 let range = line.domain();
2680 Ok(make_edge_between(model, line, range, a.0, b.0, tol)?.shape)
2681 };
2682 let bottom = segment(model, (&v_axis0, c0), (&v_rim0, meridian[0]))?;
2683 let side = make_edge_between(model, spline, domain, &v_rim0, &v_rim1, tol)?.shape;
2684 let top_edge = segment(
2685 model,
2686 (&v_rim1, meridian[meridian.len() - 1]),
2687 (&v_axis1, top),
2688 )?;
2689 let axis_edge = segment(model, (&v_axis1, top), (&v_axis0, c0))?;
2690 let wire = ogeom_algo::make_wire(model, &[bottom, side, top_edge, axis_edge], tol)?.shape;
2691 let inside = c0
2694 + z * (heights[heights.len() - 1] * 0.5)
2695 + x * (circles
2696 .iter()
2697 .map(|c| c.radius())
2698 .fold(f64::INFINITY, f64::min)
2699 * 0.5);
2700 let plane = Plane::new(Frame::new(
2701 inside,
2702 Direction::new(z.cross(x), tol)?,
2703 Direction::new(x, tol)?,
2704 tol,
2705 )?);
2706 let face = ogeom_algo::make_face(model, PlaneSurface::new(plane).into(), &[wire], tol)?.shape;
2707 let axis = ogeom_math::Axis {
2708 location: c0,
2709 direction: Direction::new(z, tol)?,
2710 };
2711 let built = ogeom_algo::make_revolution(model, &face, axis, core::f64::consts::TAU, tol)?;
2712 Ok(Some(built))
2713}
2714
2715fn cornered_loft(
2722 model: &mut Model,
2723 sections: &[Shape],
2724 along: Option<SectionMotion<'_>>,
2725 tolerance: f64,
2726 tol: Tolerances,
2727) -> OgeomResult<Option<Built>> {
2728 let mut rings: Vec<Vec<Shape>> = Vec::with_capacity(sections.len());
2729 for wire in sections {
2730 if model.kind_of(wire)? != ShapeType::Wire || !ogeom_algo::is_wire_closed(model, wire, tol)?
2731 {
2732 return Ok(None);
2733 }
2734 rings.push(model.ordered_children_of(wire)?);
2735 }
2736 let count = rings[0].len();
2737 if count < 2 || rings.iter().any(|r| r.len() != count) {
2738 return Ok(None);
2739 }
2740 let (Some(plane0), Some(plane1)) = (
2741 ogeom_algo::find_plane(model, §ions[0], tol)?,
2742 ogeom_algo::find_plane(model, §ions[sections.len() - 1], tol)?,
2743 ) else {
2744 return Ok(None);
2745 };
2746 const ALONG: usize = 16;
2747 let mut edge_curves: Vec<Vec<(ogeom_geom::Curve, (f64, f64))>> =
2750 Vec::with_capacity(rings.len());
2751 for ring in &rings {
2752 let mut per_edge = Vec::with_capacity(count);
2753 for edge in ring {
2754 let (curve, range) = spine_curve_of(model, edge)?;
2755 let curve = curve.transformed(&edge.transform(model.datums())?, tol)?;
2756 let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
2757 per_edge.push((curve, if reversed { (range.1, range.0) } else { range }));
2758 }
2759 edge_curves.push(per_edge);
2760 }
2761 let edge_at = |s: usize, e: usize, f: f64| -> OgeomResult<Point> {
2762 let (curve, (a, b)) = &edge_curves[s][e];
2763 curve.point_at(a + (b - a) * f, tol)
2764 };
2765 let samples: Vec<Vec<Vec<Point>>> = (0..rings.len())
2766 .map(|s| {
2767 (0..count)
2768 .map(|e| fractions(ALONG).iter().map(|f| edge_at(s, e, *f)).collect())
2769 .collect()
2770 })
2771 .collect::<OgeomResult<_>>()?;
2772 let corners = |model: &mut Model, s: usize| -> Vec<Shape> {
2773 (0..count)
2774 .map(|e| ogeom_algo::make_vertex(model, samples[s][e][0]).shape)
2775 .collect()
2776 };
2777 let (from, to) = (corners(model, 0), corners(model, sections.len() - 1));
2778 let middle = &samples[sections.len() / 2];
2779 let hint = {
2780 let all: Vec<Point> = middle.iter().flatten().copied().collect();
2781 #[allow(clippy::cast_precision_loss)]
2782 let n = all.len() as f64;
2783 Point::from_vector(
2784 all.iter()
2785 .fold(Vector::new(0.0, 0.0, 0.0), |acc, p| acc + p.to_vector())
2786 / n,
2787 )
2788 };
2789 let mut faces = Vec::with_capacity(count + 2);
2790 let (mut bottoms, mut tops) = (Vec::with_capacity(count), Vec::with_capacity(count));
2791 let mut first_rail: Option<Shape> = None;
2792 let mut prev_rail: Option<Shape> = None;
2793 for e in 0..count {
2794 let skin = match along {
2795 Some(motion) => Skin::swept(
2796 move |f, s| {
2797 if s.fract() == 0.0 {
2798 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2799 let i = s as usize;
2800 return edge_at(i, e, f);
2801 }
2802 Ok(motion(s)?.apply(edge_at(0, e, f)?))
2803 },
2804 fractions(ALONG),
2805 (0, rings.len() - 1),
2806 false,
2807 )?,
2808 None => Skin::columns(
2809 |f| (0..rings.len()).map(|s| edge_at(s, e, f)).collect(),
2810 fractions(ALONG),
2811 false,
2812 true,
2813 false,
2814 )?,
2815 };
2816 let next = (e + 1) % count;
2817 let last_rail = if e + 1 == count {
2818 first_rail.clone()
2819 } else {
2820 None
2821 };
2822 let strip = skinned_strip(
2823 model,
2824 &skin,
2825 (&from[e], &from[next], &to[e], &to[next]),
2826 [None, None, prev_rail.as_ref(), last_rail.as_ref()],
2827 hint,
2828 false,
2829 tolerance,
2830 tol,
2831 )?;
2832 if e == 0 {
2833 first_rail = Some(strip.rail0.clone());
2834 }
2835 prev_rail = Some(strip.rail1.clone());
2836 faces.push(strip.face.clone());
2837 bottoms.push(strip.bottom);
2838 tops.push(strip.top);
2839 }
2840 let towards = hint - samples[0][0][0];
2841 let n0 = plane0.normal().vector();
2842 let n0 = if n0.dot(towards) > 0.0 { -n0 } else { n0 };
2843 let away = hint - samples[sections.len() - 1][0][0];
2844 let n1 = plane1.normal().vector();
2845 let n1 = if n1.dot(away) > 0.0 { -n1 } else { n1 };
2846 faces.push(plane_cap(model, samples[0][0][0], n0, &[bottoms], tol)?);
2847 faces.push(plane_cap(
2848 model,
2849 samples[sections.len() - 1][0][0],
2850 n1,
2851 &[tops],
2852 tol,
2853 )?);
2854 let sewn = sew(model, &faces, tol)?;
2855 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
2856 ogeom_bail!(Construction, "the cornered loft did not close");
2857 }
2858 Ok(Some(make_solid(model, &sewn.shells)?))
2859}
2860
2861fn plane_cap(
2865 model: &mut Model,
2866 at: Point,
2867 outward: Vector,
2868 loops: &[Vec<Shape>],
2869 tol: Tolerances,
2870) -> OgeomResult<Shape> {
2871 let cap_plane = Plane::through(at, Direction::new(outward, tol)?);
2872 let mut reach = 1.0_f64;
2873 for edges in loops {
2874 for edge in edges {
2875 let (curve, range) = spine_curve_of(model, edge)?;
2876 for k in 0..8 {
2877 let p = curve.point_at(range.0 + (range.1 - range.0) * f64::from(k) / 8.0, tol)?;
2878 reach = reach.max(p.distance(at) * 2.0);
2879 }
2880 }
2881 }
2882 let surface: SurfaceGeometry =
2883 PlaneSurface::over(cap_plane, (-reach, reach), (-reach, reach))?.into();
2884 let turns: Vec<f64> = loops
2888 .iter()
2889 .map(|edges| ring_turning(model, edges, outward, tol))
2890 .collect::<OgeomResult<_>>()?;
2891 let outer = turns
2892 .iter()
2893 .enumerate()
2894 .max_by(|a, b| a.1.abs().total_cmp(&b.1.abs()))
2895 .map_or(0, |(i, _)| i);
2896 let mut wires = Vec::with_capacity(loops.len());
2897 for (i, (edges, turn)) in loops.iter().zip(&turns).enumerate() {
2898 let ring = if (*turn > 0.0) == (i == outer) {
2899 edges.clone()
2900 } else {
2901 walked_back(edges)
2902 };
2903 wires.push(ogeom_algo::make_wire(model, &ring, tol)?.shape);
2904 }
2905 let face = ogeom_algo::make_face(model, surface, &wires, tol)?.shape;
2906 let cap_id = {
2907 let Some(ogeom_topo::NodeData::Face(data)) = model.node(&face).map(|n| n.data()) else {
2908 ogeom_bail!(Construction, "the cap holds no face data");
2909 };
2910 data.surface
2911 };
2912 let frame = cap_plane.frame();
2913 let flat = |p: Point| {
2914 let local = frame.to_local(p);
2915 Point2::new(local.x, local.y)
2916 };
2917 for edges in loops {
2918 for edge in edges {
2919 let (curve, range) = spine_curve_of(model, edge)?;
2920 let pcurve: ogeom_geom::PlanarCurve = match &curve {
2921 ogeom_geom::Curve::BSpline(bs) => {
2922 let control2: Vec<Point2> = bs
2923 .control_points()
2924 .iter()
2925 .map(|w| flat(w.point()))
2926 .collect();
2927 ogeom_geom::BSpline2d::new(bs.knots().clone(), control2, tol)?.into()
2928 }
2929 ogeom_geom::Curve::Line(line) => {
2930 let axis = line.axis();
2931 let origin = flat(axis.location);
2932 let ahead = flat(axis.location + axis.direction.vector());
2933 ogeom_geom::Line2d::over(
2934 ogeom_math::Axis2::through(origin, ahead, tol)?,
2935 range.0,
2936 range.1,
2937 )?
2938 .into()
2939 }
2940 _ => ogeom_bail!(Construction, "a cap edge is neither a spline nor a line"),
2941 };
2942 ogeom_algo::attach_pcurve(
2943 model,
2944 edge,
2945 pcurve,
2946 cap_id,
2947 ogeom_topo::Location::identity(),
2948 range,
2949 )?;
2950 }
2951 }
2952 Ok(face)
2953}
2954
2955pub fn make_loft_skinned(
2970 model: &mut Model,
2971 sections: &[Shape],
2972 tolerance: f64,
2973 tol: Tolerances,
2974) -> OgeomResult<Built> {
2975 loft_skinned_along(model, sections, None, tolerance, tol)
2976}
2977
2978type SectionMotion<'a> = &'a dyn Fn(f64) -> OgeomResult<Transform>;
2982
2983fn loft_skinned_along(
2987 model: &mut Model,
2988 sections: &[Shape],
2989 along: Option<SectionMotion<'_>>,
2990 tolerance: f64,
2991 tol: Tolerances,
2992) -> OgeomResult<Built> {
2993 if sections.len() < 2 {
2994 ogeom_bail!(Construction, "a loft needs at least two sections");
2995 }
2996 let to_point = model.kind_of(§ions[sections.len() - 1])? == ShapeType::Vertex;
2997 if sections.len() == 2
3001 && !to_point
3002 && along.is_none()
3003 && let Ok(built) = make_loft(model, §ions[0], §ions[1], tol)
3004 {
3005 return Ok(built);
3006 }
3007 if sections.len() > 2 && !to_point {
3008 if let Some(built) = coaxial_circles_loft(model, sections, tol)? {
3009 return Ok(built);
3010 }
3011 if let Some(built) = cornered_loft(model, sections, along, tolerance, tol)? {
3012 return Ok(built);
3013 }
3014 }
3015 let apex = match model.kind_of(§ions[sections.len() - 1])? {
3018 ShapeType::Vertex => {
3019 if sections.len() < 2 {
3020 ogeom_bail!(
3021 Construction,
3022 "a loft to a point needs a section to start from"
3023 );
3024 }
3025 let Some(data) = model
3026 .node(§ions[sections.len() - 1])
3027 .and_then(|n| n.data().as_vertex())
3028 else {
3029 ogeom_bail!(Construction, "the apex vertex holds no point");
3030 };
3031 Some(data.point)
3032 }
3033 _ => None,
3034 };
3035 let wires = §ions[..sections.len() - usize::from(apex.is_some())];
3036 let mut loops: Vec<Section> = Vec::with_capacity(sections.len() + 1);
3037 let mut cap_planes: Vec<Option<Plane>> = Vec::with_capacity(wires.len());
3038 for wire in wires {
3039 if model.kind_of(wire)? != ShapeType::Wire {
3040 ogeom_bail!(Construction, "a loft section is a wire");
3041 }
3042 if !ogeom_algo::is_wire_closed(model, wire, tol)? {
3043 ogeom_bail!(Construction, "a loft section must be closed");
3044 }
3045 cap_planes.push(ogeom_algo::find_plane(model, wire, tol)?);
3049 loops.push(Section::Loop(section_loop(model, wire, None, tol)?));
3050 }
3051 let outward_at = |rows: &[Vec<Point>], planes: &[Option<Plane>], end: bool| -> EndCap {
3054 let (i, j) = if end {
3055 (rows.len() - 1, rows.len() - 2)
3056 } else {
3057 (0, 1)
3058 };
3059 let Some(plane) = &planes[i] else {
3060 return EndCap::Skinned;
3061 };
3062 let towards = rows[j][0] - rows[i][0];
3063 let n = plane.normal().vector();
3064 EndCap::Plane(if n.dot(towards) > 0.0 { -n } else { n })
3065 };
3066 let mut built = if let Some(apex) = apex {
3067 if loops.len() < 2
3068 && let Some(Section::Loop(ring)) = loops.first()
3069 {
3070 let half = Section::Halfway(ring.clone(), apex);
3074 loops.push(half);
3075 }
3076 loops.push(Section::Point(apex));
3077 let skin = Skin::sections(loops, false)?;
3078 let rows = &skin.rows;
3079 let outward0 = match outward_at(rows, &cap_planes, false) {
3080 EndCap::Plane(n) => n,
3081 EndCap::Skinned => {
3082 ogeom_bail!(
3083 Construction,
3084 "a loft to a point starts from a planar section; a cap stands on it"
3085 );
3086 }
3087 };
3088 skinned_solid_to_apex(model, &skin, outward0, tolerance, tol)?
3089 } else {
3090 let skin = match along {
3091 Some(motion) => {
3092 let Some(Section::Loop(first)) = loops.first().cloned() else {
3093 ogeom_bail!(Construction, "a loft's first section is a loop");
3094 };
3095 Skin::swept(
3096 move |f, s| {
3097 if s.fract() == 0.0 {
3098 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
3099 let i = s as usize;
3100 return Ok(loops[i].at(f));
3101 }
3102 Ok(motion(s)?.apply(first.at(f)))
3103 },
3104 fractions(AROUND_SECTION),
3105 (0, sections.len() - 1),
3106 true,
3107 )?
3108 }
3109 None => Skin::sections(loops, false)?,
3110 };
3111 let outward0 = outward_at(&skin.rows, &cap_planes, false);
3112 let outward1 = outward_at(&skin.rows, &cap_planes, true);
3113 skinned_solid(model, &skin, (outward0, outward1), tolerance, tol)?
3114 };
3115 for section in sections {
3116 built.history.generate(section, built.shape.clone());
3117 }
3118 Ok(built)
3119}
3120
3121fn sample_wire(
3123 model: &Model,
3124 wire: &Shape,
3125 count: usize,
3126 tol: Tolerances,
3127) -> OgeomResult<Vec<Point>> {
3128 sample_wire_from(model, wire, count, None, tol)
3129}
3130
3131fn sample_wire_from(
3135 model: &Model,
3136 wire: &Shape,
3137 count: usize,
3138 start_hint: Option<Point>,
3139 tol: Tolerances,
3140) -> OgeomResult<Vec<Point>> {
3141 let arc = section_loop(model, wire, start_hint, tol)?;
3142 Ok((0..count)
3143 .map(|s| {
3144 #[allow(clippy::cast_precision_loss, reason = "a small count")]
3145 let f = s as f64 / count as f64;
3146 arc.at(f)
3147 })
3148 .collect())
3149}
3150
3151fn section_loop(
3154 model: &Model,
3155 wire: &Shape,
3156 start_hint: Option<Point>,
3157 tol: Tolerances,
3158) -> OgeomResult<ArcLoop> {
3159 let mut dense = dense_wire(model, wire, tol)?;
3160 if let Some(hint) = start_hint {
3161 let mut best = 0usize;
3162 let mut held = f64::INFINITY;
3163 for (i, p) in dense.iter().enumerate() {
3164 let d = p.distance(hint);
3165 if d < held {
3166 held = d;
3167 best = i;
3168 }
3169 }
3170 dense.rotate_left(best);
3171 }
3172 Ok(ArcLoop::new(dense))
3173}
3174
3175fn dense_wire(model: &Model, wire: &Shape, tol: Tolerances) -> OgeomResult<Vec<Point>> {
3178 let mut dense: Vec<Point> = Vec::new();
3179 for edge in explore(model, wire, Filter::OfType(ShapeType::Edge))? {
3180 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
3181 ogeom_bail!(Construction, "a section edge holds no data");
3182 };
3183 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
3184 ogeom_bail!(Construction, "a section edge has no curve");
3185 };
3186 let Some(geometry) = model.geometry().curve(*curve) else {
3187 ogeom_bail!(Dangling, "curve is not in this model");
3188 };
3189 let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
3190 let placement = edge.transform(model.datums())?;
3192 let at = |f: f64| -> OgeomResult<Point> {
3193 let t = if reversed {
3194 range.1 - (range.1 - range.0) * f
3195 } else {
3196 range.0 + (range.1 - range.0) * f
3197 };
3198 Ok(placement.apply(geometry.point_at(t, tol)?))
3199 };
3200 const FIRST: u32 = 64;
3205 const MOST: u32 = 8192;
3206 let mut sag = 0.0_f64;
3207 for i in 0..FIRST {
3208 let (f0, f1) = (
3209 f64::from(i) / f64::from(FIRST),
3210 f64::from(i + 1) / f64::from(FIRST),
3211 );
3212 let (a, b) = (at(f0)?, at(f1)?);
3213 let mid = at(f64::midpoint(f0, f1))?;
3214 sag = sag.max(mid.distance(a.midpoint(b)));
3215 }
3216 let allowed = tol.confusion() * 10.0;
3217 #[allow(
3218 clippy::cast_possible_truncation,
3219 clippy::cast_sign_loss,
3220 reason = "a sample count, bounded"
3221 )]
3222 let count = if sag <= allowed {
3223 FIRST
3224 } else {
3225 ((f64::from(FIRST) * (sag / allowed).sqrt()).ceil() as u32).clamp(FIRST, MOST)
3226 };
3227 for i in 0..count {
3228 dense.push(at(f64::from(i) / f64::from(count))?);
3229 }
3230 }
3231 if dense.is_empty() {
3232 ogeom_bail!(Construction, "a section has no edges");
3233 }
3234 Ok(dense)
3235}
3236
3237fn matched_loop(dense: &[Point], count: usize, previous: &[Point]) -> ArcLoop {
3249 let n = dense.len();
3250 let ways = [
3251 ArcLoop::new(dense.to_vec()),
3252 ArcLoop::new(dense.iter().rev().copied().collect()),
3253 ];
3254 #[allow(clippy::cast_precision_loss, reason = "a sample count")]
3255 fn samples(arc: &ArcLoop, start: usize, count: usize) -> impl Iterator<Item = Point> + '_ {
3256 let offset = arc.lengths[start] / arc.total;
3257 (0..count).map(move |k| {
3258 let f = offset + k as f64 / count as f64;
3259 arc.at(if f >= 1.0 { f - 1.0 } else { f })
3260 })
3261 }
3262 let cost = |arc: &ArcLoop, start: usize| -> f64 {
3263 samples(arc, start, count)
3264 .zip(previous)
3265 .map(|(p, q)| (p - *q).dot(p - *q))
3266 .sum()
3267 };
3268 let scale: f64 = previous
3269 .iter()
3270 .copied()
3271 .chain(samples(&ways[0], 0, count))
3272 .map(|p| (p - Point::ORIGIN).dot(p - Point::ORIGIN))
3273 .sum();
3274 let slack = scale * 1e-12;
3275 let coarse = 8 * count.max(1);
3276 let mut held = cost(&ways[0], 0);
3277 let mut best = (0, 0);
3278 for (w, arc) in ways.iter().enumerate() {
3279 let mut tried = vec![n <= 2 * coarse; n];
3280 if n > 2 * coarse {
3281 let picks: Vec<usize> = (0..coarse).map(|j| j * n / coarse).collect();
3282 let costs: Vec<f64> = picks.iter().map(|&i| cost(arc, i)).collect();
3283 for j in 0..coarse {
3284 let (before, after) = ((j + coarse - 1) % coarse, (j + 1) % coarse);
3285 if costs[j] <= costs[before] && costs[j] <= costs[after] {
3286 let (from, to) = (picks[before], picks[after]);
3287 let mut i = from;
3288 loop {
3289 tried[i] = true;
3290 if i == to {
3291 break;
3292 }
3293 i = (i + 1) % n;
3294 }
3295 }
3296 }
3297 }
3298 for start in (0..n).filter(|&i| tried[i]) {
3299 let c = cost(arc, start);
3300 if c < held - slack {
3301 held = c;
3302 best = (w, start);
3303 }
3304 }
3305 }
3306 let [forward, backward] = ways;
3307 let (way, start) = best;
3308 let arc = if way == 0 { forward } else { backward };
3309 if start == 0 {
3310 return arc;
3311 }
3312 let mut turned = arc.dense;
3313 turned.rotate_left(start);
3314 ArcLoop::new(turned)
3315}
3316
3317pub fn make_pipe_skinned(
3331 model: &mut Model,
3332 spine: &Shape,
3333 radius: f64,
3334 tolerance: f64,
3335 tol: Tolerances,
3336) -> OgeomResult<Built> {
3337 if !radius.is_finite() || radius <= tol.confusion() {
3338 ogeom_bail!(Construction, "a pipe of radius {radius} holds nothing");
3339 }
3340 let (curve, range) = {
3341 let Some(data) = model.node(spine).and_then(|n| n.data().as_edge()) else {
3342 ogeom_bail!(Construction, "a pipe runs along an edge");
3343 };
3344 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
3345 ogeom_bail!(Construction, "the spine has no curve");
3346 };
3347 let Some(geometry) = model.geometry().curve(*curve) else {
3348 ogeom_bail!(Dangling, "curve is not in this model");
3349 };
3350 (geometry.clone(), *range)
3351 };
3352 const STATIONS: usize = 33;
3356 const FRAMES: usize = 64 * (STATIONS - 1) + 1;
3357 const AROUND: usize = 40;
3358 let mut stations: Vec<SpineStation> = Vec::with_capacity(FRAMES);
3359 for i in 0..FRAMES {
3360 #[allow(clippy::cast_precision_loss)]
3361 let t = range.0 + (range.1 - range.0) * (i as f64) / ((FRAMES - 1) as f64);
3362 let p = curve.point_at(t, tol)?;
3363 let d = curve.d1_at(t, tol)?;
3364 let m = d.magnitude();
3365 if m <= tol.confusion() {
3366 ogeom_bail!(Construction, "the spine is degenerate at {t}");
3367 }
3368 stations.push(SpineStation {
3369 at: p,
3370 tangent: d / m,
3371 edge: 0,
3372 t,
3373 });
3374 }
3375 let normals = rmf_normals(&stations);
3376 let tube = |u: f64, t: f64| -> OgeomResult<Point> {
3382 let k = stations.partition_point(|s| s.t <= t).clamp(1, FRAMES) - 1;
3383 let p = curve.point_at(t, tol)?;
3384 let d = curve.d1_at(t, tol)?;
3385 let m = d.magnitude();
3386 if m <= tol.confusion() {
3387 ogeom_bail!(Construction, "the spine is degenerate at {t}");
3388 }
3389 let tangent = d / m;
3390 let x = rmf_step(stations[k].at, stations[k].tangent, normals[k], p, tangent);
3391 let y = tangent.cross(x);
3392 let ang = core::f64::consts::TAU * u;
3393 Ok(p + (x * ang.cos() + y * ang.sin()) * radius)
3394 };
3395 let fraction = |i: usize, n: usize| -> f64 {
3396 #[allow(clippy::cast_precision_loss, reason = "a small count")]
3397 let f = i as f64 / n as f64;
3398 f
3399 };
3400 let us: Vec<f64> = (0..=AROUND).map(|a| fraction(a, AROUND)).collect();
3401 let vs: Vec<f64> = stations.iter().step_by(64).map(|s| s.t).collect();
3402 let rows = vs
3403 .iter()
3404 .map(|&t| us[..AROUND].iter().map(|&u| tube(u, t)).collect())
3405 .collect::<OgeomResult<Vec<Vec<Point>>>>()?;
3406 let skin = Skin {
3407 rows,
3408 round: true,
3409 by_spacing: false,
3410 traced: Some(Traced {
3411 point: Box::new(move |u, t| tube(if u >= 1.0 { 0.0 } else { u }, t)),
3414 sampling: ogeom_geom::fit::Sampling {
3415 us,
3416 vs,
3417 between: (true, true),
3418 closed_v: false,
3419 most: 512,
3420 },
3421 }),
3422 };
3423 let mut built = skinned_solid(
3424 model,
3425 &skin,
3426 (
3427 EndCap::Plane(-stations[0].tangent),
3428 EndCap::Plane(stations[FRAMES - 1].tangent),
3429 ),
3430 tolerance,
3431 tol,
3432 )?;
3433 built.history.generate(spine, built.shape.clone());
3434 Ok(built)
3435}
3436
3437#[derive(Clone, Copy)]
3439pub(crate) struct SpineStation {
3440 pub(crate) at: Point,
3441 pub(crate) tangent: Vector,
3443 pub(crate) edge: usize,
3445 pub(crate) t: f64,
3447}
3448
3449#[allow(clippy::too_many_arguments, reason = "one frame, spelled out")]
3452fn closed_loop_shell(
3453 model: &mut Model,
3454 profile_loop: &Shape,
3455 edges: &[Shape],
3456 smooth: bool,
3457 walk: &SpineWalk<'_>,
3458 frame0: (Point, Vector),
3459 tolerance: f64,
3460 tol: Tolerances,
3461) -> OgeomResult<Shape> {
3462 const AROUND: usize = 40;
3463 let (origin, x0) = frame0;
3464 let stations = walk.stations;
3465 let t0 = stations[0].tangent;
3466 let y0 = t0.cross(x0);
3467 let home = stations.len() - 1;
3470 let carried = |s: f64, (a, b): (f64, f64)| -> OgeomResult<Point> {
3474 if s.fract() == 0.0 {
3475 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
3476 let i = (s as usize) % home;
3477 let (station, x) = (&stations[i], walk.normals[i]);
3478 return Ok(station.at + x * a + station.tangent.cross(x) * b);
3479 }
3480 walk.generator(s, (0, home), (a, b), tol)
3481 };
3482 if !smooth {
3483 const ALONG_EDGE: usize = 8;
3487 let mid_station = stations[stations.len() / 2].at;
3491 let mut faces = Vec::with_capacity(edges.len());
3492 let mut rails: Vec<Option<Shape>> = vec![None; edges.len()];
3495 for (index, edge) in edges.iter().enumerate() {
3496 let (curve, range) = spine_curve_of(model, edge)?;
3497 let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
3498 let skin = Skin::swept(
3502 |f, s| {
3503 let t = if reversed {
3504 range.1 - (range.1 - range.0) * f
3505 } else {
3506 range.0 + (range.1 - range.0) * f
3507 };
3508 let p = curve.point_at(t, tol)?;
3509 carried(s, ((p - origin).dot(x0), (p - origin).dot(y0)))
3510 },
3511 fractions(ALONG_EDGE),
3512 (0, home),
3513 false,
3514 )?;
3515 let next = (index + 1) % edges.len();
3516 let shared = [rails[index].clone(), rails[next].clone()];
3517 let (face, rail0, rail1) = skinned_ring_strip(
3518 model,
3519 &skin,
3520 mid_station,
3521 [shared[0].as_ref(), shared[1].as_ref()],
3522 tolerance,
3523 tol,
3524 )?;
3525 rails[index] = Some(rail0);
3526 rails[next] = Some(rail1);
3527 faces.push(face);
3528 }
3529 let sewn = sew(model, &faces, tol)?;
3530 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
3531 if std::env::var_os("OGEOM_DEBUG_RING").is_some() {
3532 use ogeom_geom::Curve3d as _;
3533 eprintln!(
3534 "RING: {} shells from {} strips",
3535 sewn.shells.len(),
3536 faces.len()
3537 );
3538 for shell in &sewn.shells {
3539 for edge in ogeom_topo::explore_unique(model, shell, ShapeType::Edge)? {
3540 let mut uses = 0;
3541 for f in explore(model, shell, Filter::OfType(ShapeType::Face))? {
3542 for w in model.children_of(&f)? {
3543 for e in model.children_of(&w)? {
3544 if e.node() == edge.node() {
3545 uses += 1;
3546 }
3547 }
3548 }
3549 }
3550 if uses == 1
3551 && let Some(d) = model.node(&edge).and_then(|n| n.data().as_edge())
3552 && let Some(ogeom_topo::EdgeRepr::Curve3d { curve, range, .. }) =
3553 d.curve3d()
3554 && let Some(g) = model.geometry().curve(*curve)
3555 {
3556 eprintln!(
3557 "RING open edge tol {:.2e}: {:?} -> {:?}",
3558 d.tolerance.get(),
3559 g.point_at(range.0, tol)?,
3560 g.point_at(range.1, tol)?
3561 );
3562 }
3563 }
3564 }
3565 }
3566 ogeom_bail!(Construction, "the faceted ring did not close");
3567 }
3568 return Ok(sewn.shells[0].clone());
3569 }
3570 let around = section_loop(model, profile_loop, None, tol)?;
3573 let skin = Skin::swept(
3574 |f, s| {
3575 let p = around.at(f);
3576 carried(s, ((p - origin).dot(x0), (p - origin).dot(y0)))
3577 },
3578 fractions(AROUND),
3579 (0, home),
3580 true,
3581 )?;
3582 closed_skinned_shell(model, &skin, tolerance, tol)
3583}
3584
3585fn rmf_normals(stations: &[SpineStation]) -> Vec<Vector> {
3590 let mut normals: Vec<Vector> = Vec::with_capacity(stations.len());
3591 let t0 = stations[0].tangent;
3592 let seed = if t0.cross(ogeom_math::Vector::Z).magnitude() > 0.5 {
3593 ogeom_math::Vector::Z
3594 } else {
3595 ogeom_math::Vector::X
3596 };
3597 let n0 = {
3598 let v = seed - t0 * seed.dot(t0);
3599 v / v.magnitude()
3600 };
3601 normals.push(n0);
3602 for i in 1..stations.len() {
3603 let (p0, t0) = (stations[i - 1].at, stations[i - 1].tangent);
3604 let (p1, t1) = (stations[i].at, stations[i].tangent);
3605 let n = normals[i - 1];
3606 let v1 = p1 - p0;
3607 let c1 = v1.dot(v1);
3608 if c1 <= 1e-20 {
3609 let bisector = t0 + t1;
3619 let m = bisector.magnitude();
3620 if m <= 1e-12 {
3621 normals.push(n);
3622 continue;
3623 }
3624 let b = bisector / m;
3625 normals.push(n - b * (2.0 * n.dot(b)));
3626 continue;
3627 }
3628 normals.push(rmf_step(p0, t0, n, p1, t1));
3629 }
3630 normals
3631}
3632
3633fn rmf_step(p0: Point, t0: Vector, n0: Vector, p1: Point, t1: Vector) -> Vector {
3636 let v1 = p1 - p0;
3637 let c1 = v1.dot(v1);
3638 if c1 <= 1e-20 {
3639 return n0;
3640 }
3641 let nl = n0 - v1 * (2.0 / c1 * v1.dot(n0));
3642 let tl = t0 - v1 * (2.0 / c1 * v1.dot(t0));
3643 let v2 = t1 - tl;
3644 let c2 = v2.dot(v2);
3645 let next = if c2 > 1e-20 {
3646 nl - v2 * (2.0 / c2 * v2.dot(nl))
3647 } else {
3648 nl
3649 };
3650 next / next.magnitude()
3651}
3652
3653struct SpineWalk<'a> {
3660 curves: Vec<WalkCurve>,
3661 stations: &'a [SpineStation],
3662 normals: &'a [Vector],
3663}
3664
3665type WalkCurve = (ogeom_geom::Curve, (f64, f64), bool);
3667
3668fn walk_curves(model: &Model, spine: &Shape) -> OgeomResult<Vec<WalkCurve>> {
3670 let edges: Vec<Shape> = match model.kind_of(spine)? {
3671 ShapeType::Edge => vec![spine.clone()],
3672 _ => model.ordered_children_of(spine)?,
3673 };
3674 let mut out = Vec::with_capacity(edges.len());
3675 for edge in &edges {
3676 let (curve, range) = spine_curve_of(model, edge)?;
3677 out.push((
3678 curve,
3679 range,
3680 edge.orientation() == ogeom_topo::Orientation::Reversed,
3681 ));
3682 }
3683 Ok(out)
3684}
3685
3686struct CornerJoin {
3690 at: Point,
3691 s1: f64,
3692 s2: f64,
3693 gap: f64,
3694}
3695
3696impl SpineWalk<'_> {
3697 fn frame_at(
3700 &self,
3701 s: f64,
3702 (rs, re): (usize, usize),
3703 tol: Tolerances,
3704 ) -> OgeomResult<(Point, Vector, Vector)> {
3705 let st = self.stations;
3706 let at = |i: usize| (st[i].at, st[i].tangent, self.normals[i]);
3707 #[allow(clippy::cast_precision_loss)]
3708 let (rsf, ref_) = (rs as f64, re as f64);
3709 if s <= rsf {
3710 let (p, t, n) = at(rs);
3711 let h = st[rs].at.distance(st[(rs + 1).min(re)].at);
3712 return Ok((p + t * ((s - rsf) * h), t, n));
3713 }
3714 if s >= ref_ {
3715 let (p, t, n) = at(re);
3716 let h = st[re].at.distance(st[re.saturating_sub(1).max(rs)].at);
3717 return Ok((p + t * ((s - ref_) * h), t, n));
3718 }
3719 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
3720 let j = s.floor() as usize;
3721 #[allow(clippy::cast_precision_loss)]
3722 let f = s - j as f64;
3723 if f <= 0.0 {
3724 return Ok(at(j));
3725 }
3726 let (curve, range, reversed) = &self.curves[st[j + 1].edge];
3727 let t_from = if st[j].edge == st[j + 1].edge {
3728 st[j].t
3729 } else if *reversed {
3730 range.1
3731 } else {
3732 range.0
3733 };
3734 let t = t_from + (st[j + 1].t - t_from) * f;
3735 let p = curve.point_at(t, tol)?;
3736 let d = curve.d1_at(t, tol)?;
3737 let m = d.magnitude();
3738 if m <= tol.confusion() {
3739 ogeom_bail!(Construction, "the spine is degenerate at {t}");
3740 }
3741 let tangent = if *reversed { -(d / m) } else { d / m };
3742 let n = rmf_step(st[j].at, st[j].tangent, self.normals[j], p, tangent);
3743 let carried = rmf_step(
3748 st[j].at,
3749 st[j].tangent,
3750 self.normals[j],
3751 st[j + 1].at,
3752 st[j + 1].tangent,
3753 );
3754 let ahead = self.normals[j + 1];
3755 let turn = carried
3756 .cross(ahead)
3757 .dot(st[j + 1].tangent)
3758 .atan2(carried.dot(ahead));
3759 if turn == 0.0 {
3760 return Ok((p, tangent, n));
3761 }
3762 let phi = turn * f;
3763 let n = n * phi.cos() + tangent.cross(n) * phi.sin();
3764 let n = n - tangent * n.dot(tangent);
3765 Ok((p, tangent, n / n.magnitude()))
3766 }
3767
3768 fn generator(
3770 &self,
3771 s: f64,
3772 run: (usize, usize),
3773 (a, b): (f64, f64),
3774 tol: Tolerances,
3775 ) -> OgeomResult<Point> {
3776 let (p, t, x) = self.frame_at(s, run, tol)?;
3777 let y = t.cross(x);
3778 Ok(p + x * a + y * b)
3779 }
3780
3781 fn join(
3785 &self,
3786 before: (usize, usize),
3787 after: (usize, usize),
3788 ab: (f64, f64),
3789 tol: Tolerances,
3790 ) -> OgeomResult<CornerJoin> {
3791 const STEP: f64 = 1e-4;
3792 #[allow(clippy::cast_precision_loss)]
3793 let (mut s1, mut s2) = (before.1 as f64, after.0 as f64);
3794 for _ in 0..60 {
3795 let g1 = self.generator(s1, before, ab, tol)?;
3796 let g2 = self.generator(s2, after, ab, tol)?;
3797 let f = g1 - g2;
3798 let d1 = (self.generator(s1 + STEP, before, ab, tol)?
3799 - self.generator(s1 - STEP, before, ab, tol)?)
3800 / (2.0 * STEP);
3801 let d2 = (self.generator(s2 + STEP, after, ab, tol)?
3802 - self.generator(s2 - STEP, after, ab, tol)?)
3803 / (2.0 * STEP);
3804 let (a11, a12, a22) = (d1.dot(d1), -d1.dot(d2), d2.dot(d2));
3805 let (b1, b2) = (-f.dot(d1), f.dot(d2));
3806 let det = a11 * a22 - a12 * a12;
3807 if det.abs() <= 1e-30 {
3808 break;
3809 }
3810 let e1 = (b1 * a22 - a12 * b2) / det;
3811 let e2 = (a11 * b2 - a12 * b1) / det;
3812 s1 += e1;
3813 s2 += e2;
3814 if e1.abs().max(e2.abs()) <= 1e-12 {
3815 break;
3816 }
3817 }
3818 let g1 = self.generator(s1, before, ab, tol)?;
3819 let g2 = self.generator(s2, after, ab, tol)?;
3820 Ok(CornerJoin {
3821 at: g1.midpoint(g2),
3822 s1,
3823 s2,
3824 gap: g1.distance(g2),
3825 })
3826 }
3827}
3828
3829#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
3857pub fn make_helical_sweep(
3858 model: &mut Model,
3859 profile: &Shape,
3860 axis: ogeom_math::Axis,
3861 pitch: f64,
3862 turns: f64,
3863 left_handed: bool,
3864 taper_per_turn: f64,
3865 tol: Tolerances,
3866) -> OgeomResult<Built> {
3867 if !(pitch.is_finite() && pitch >= 0.0) || !(turns.is_finite() && turns > 0.0) {
3868 ogeom_bail!(
3869 Construction,
3870 "a helical sweep needs a pitch of zero or more and a positive turn count; \
3871 got {pitch} and {turns}"
3872 );
3873 }
3874 if !taper_per_turn.is_finite() {
3875 ogeom_bail!(Construction, "a taper of {taper_per_turn} is not a length");
3876 }
3877 let flat = pitch <= tol.confusion();
3878 if flat && taper_per_turn.abs() <= tol.confusion() {
3879 ogeom_bail!(
3880 Construction,
3881 "a helical sweep with no pitch and no taper turns the profile onto itself"
3882 );
3883 }
3884 if model.kind_of(profile)? != ShapeType::Face {
3885 ogeom_bail!(Construction, "a helical sweep sweeps a planar face");
3886 }
3887 let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
3888 ogeom_bail!(Construction, "a helical sweep sweeps a planar face");
3889 };
3890 let z = axis.direction.vector();
3891 let n = plane.normal().vector();
3892 let holds_axis = n.dot(z).abs() <= tol.angular()
3893 && plane.distance_to(axis.location) <= tol.confusion() * 100.0;
3894 let level = (n.dot(z).abs() - 1.0).abs() <= tol.angular();
3895 if taper_per_turn.abs() > tol.confusion() && !holds_axis && !level {
3899 ogeom_bail!(
3900 Construction,
3901 "a tapered helical sweep carries a profile in a plane through the \
3902 axis or square to it; an oblique profile would leave its plane"
3903 );
3904 }
3905 if !holds_axis && n.dot(z).abs() > tol.angular() {
3909 let reach = (plane.origin() - axis.location).dot(n) / z.dot(n);
3910 let pierce = axis.location + z * reach;
3911 let frame = plane.frame();
3912 let flat = |p: Point| {
3913 let l = frame.to_local(p);
3914 Point2::new(l.x, l.y)
3915 };
3916 let at = flat(pierce);
3917 let mut inside = false;
3918 let mut touches = false;
3919 for wire in explore(model, profile, Filter::OfType(ShapeType::Wire))? {
3920 let ring: Vec<Point2> = sample_wire(model, &wire, 256, tol)?
3921 .into_iter()
3922 .map(flat)
3923 .collect();
3924 for (k, a) in ring.iter().enumerate() {
3925 let b = ring[(k + 1) % ring.len()];
3926 let (ab, ap) = (b - *a, at - *a);
3927 let t = (ap.dot(ab) / ab.dot(ab).max(f64::MIN_POSITIVE)).clamp(0.0, 1.0);
3928 touches |= (*a + ab * t).distance(at) <= tol.confusion() * 100.0;
3929 if (a.y > at.y) != (b.y > at.y) && at.x < a.x + (at.y - a.y) / (b.y - a.y) * ab.x {
3930 inside = !inside;
3931 }
3932 }
3933 }
3934 if inside || touches {
3935 ogeom_bail!(
3936 Construction,
3937 "the profile meets the axis, where a helical sweep has no helix \
3938 to follow"
3939 );
3940 }
3941 }
3942 let total = core::f64::consts::TAU * turns;
3943 let sense = if left_handed { -1.0 } else { 1.0 };
3944 let travel = |p: Point| -> Vector {
3947 let foot = axis.project(p);
3948 let out = p - foot;
3949 let rho = out.magnitude().max(f64::MIN_POSITIVE);
3950 let radial = out / rho;
3951 (z * pitch + radial * taper_per_turn) / core::f64::consts::TAU
3952 + z.cross(radial) * (rho * sense)
3953 };
3954 let screw = |p: Point, theta: f64| -> OgeomResult<Point> {
3956 let foot = axis.project(p);
3957 let out = p - foot;
3958 let rho = out.magnitude();
3959 let grown = rho + taper_per_turn * theta / core::f64::consts::TAU;
3960 if rho <= tol.confusion() || grown <= tol.confusion() {
3961 ogeom_bail!(
3962 Construction,
3963 "the profile reaches the axis, where a helical sweep has no \
3964 helix to follow"
3965 );
3966 }
3967 let radial = out / rho;
3968 let across = z.cross(radial);
3969 let (sin, cos) = (sense * theta).sin_cos();
3970 let turned = radial * cos + across * sin;
3971 Ok(foot + z * (pitch * theta / core::f64::consts::TAU) + turned * grown)
3972 };
3973
3974 let loops = explore(model, profile, Filter::OfType(ShapeType::Wire))?;
3976 if loops.is_empty() {
3977 ogeom_bail!(Construction, "the profile has no loop to sweep");
3978 }
3979 {
3983 let (mut ahead, mut behind) = (0.0_f64, 0.0_f64);
3984 for wire in &loops {
3985 for p in sample_wire(model, wire, 64, tol)? {
3986 let t = travel(p);
3987 let across = t.dot(n) / t.magnitude().max(f64::MIN_POSITIVE);
3988 ahead = ahead.max(across);
3989 behind = behind.max(-across);
3990 }
3991 }
3992 if ahead > tol.angular() && behind > tol.angular() {
3993 ogeom_bail!(
3994 Construction,
3995 "the screw carries part of the profile forward through its plane \
3996 and part back; the sweep runs into itself"
3997 );
3998 }
3999 if ahead <= tol.angular() && behind <= tol.angular() {
4000 ogeom_bail!(
4001 Construction,
4002 "the screw carries the profile along its own plane; it sweeps \
4003 no volume"
4004 );
4005 }
4006 }
4007 if turns > 1.0 {
4010 let mut low = f64::INFINITY;
4011 let mut high = f64::NEG_INFINITY;
4012 for wire in &loops {
4013 for p in sample_wire(model, wire, 64, tol)? {
4014 let h = if flat {
4015 p.distance(axis.project(p))
4016 } else {
4017 (p - axis.location).dot(z)
4018 };
4019 low = low.min(h);
4020 high = high.max(h);
4021 }
4022 }
4023 let (clearance, across) = if flat {
4024 (taper_per_turn.abs(), "away from the axis")
4025 } else {
4026 (pitch, "along the axis")
4027 };
4028 if high - low >= clearance - tol.confusion() {
4029 ogeom_bail!(
4030 Construction,
4031 "the profile spans {} {across}, not less than the turn's advance \
4032 {clearance}; a turn on it meets itself",
4033 high - low
4034 );
4035 }
4036 }
4037 let tolerance = tol.confusion() * 100.0;
4038 let quarter = core::f64::consts::FRAC_PI_2;
4046 let mut borders = vec![0.0];
4047 let mut next_border = quarter / 2.0;
4048 while next_border < total - quarter * 1e-3 {
4049 borders.push(next_border);
4050 next_border += quarter;
4051 }
4052 borders.push(total);
4053 let segments = borders.len() - 1;
4054 const PER_SEGMENT: usize = 48;
4057 #[allow(clippy::cast_precision_loss)]
4060 let theta_at = |seg: usize, s: f64| {
4061 borders[seg] + (borders[seg + 1] - borders[seg]) * s / (PER_SEGMENT as f64)
4062 };
4063
4064 let mut faces: Vec<Shape> = Vec::new();
4065 let mut cap_loops: [Vec<Vec<Shape>>; 2] = [Vec::new(), Vec::new()];
4066 for (li, wire) in loops.iter().enumerate() {
4067 let hole = li != 0;
4068 let edges = model.ordered_children_of(wire)?;
4069 let centre = {
4070 let samples = sample_wire(model, wire, 32, tol)?;
4071 #[allow(clippy::cast_precision_loss)]
4072 let n = samples.len() as f64;
4073 let sum = samples
4074 .iter()
4075 .fold(Vector::new(0.0, 0.0, 0.0), |acc, p| acc + p.to_vector());
4076 Point::from_vector(sum / n)
4077 };
4078 let mut pieces: Vec<(ogeom_geom::Curve, f64, f64)> = Vec::new();
4082 for edge in &edges {
4083 let (curve, range) = spine_curve_of(model, edge)?;
4084 let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
4085 let (t0, t1) = if reversed { (range.1, range.0) } else { range };
4086 let closed =
4087 ogeom_algo::edge_vertices(model, edge)?.is_some_and(|(a, b)| a.is_same(&b));
4088 let parts = if closed { 4 } else { 1 };
4089 for k in 0..parts {
4090 #[allow(clippy::cast_precision_loss)]
4091 let (f0, f1) = (k as f64 / parts as f64, (k + 1) as f64 / parts as f64);
4092 pieces.push((curve.clone(), t0 + (t1 - t0) * f0, t0 + (t1 - t0) * f1));
4093 }
4094 }
4095 let starts: Vec<Point> = pieces
4098 .iter()
4099 .map(|(curve, a, _)| curve.point_at(*a, tol))
4100 .collect::<OgeomResult<_>>()?;
4101 let count = pieces.len();
4102 let piece_at = |pi: usize, f: f64| -> OgeomResult<Point> {
4105 let (curve, a, b) = &pieces[pi];
4106 if f <= 0.0 {
4107 Ok(starts[pi])
4108 } else if f >= 1.0 {
4109 Ok(starts[(pi + 1) % count])
4110 } else {
4111 curve.point_at(a + (b - a) * f, tol)
4112 }
4113 };
4114 let along_of = |pi: usize| -> usize {
4115 if matches!(pieces[pi].0, ogeom_geom::Curve::Line(_)) {
4116 8
4117 } else {
4118 24
4119 }
4120 };
4121 let skin_of = |pi: usize, seg: usize| -> OgeomResult<Skin<'_>> {
4126 Skin::swept(
4127 move |f, s| screw(piece_at(pi, f)?, theta_at(seg, s)),
4128 fractions(along_of(pi)),
4129 (0, PER_SEGMENT),
4130 false,
4131 )
4132 };
4133
4134 let mut corners: Vec<Vec<Shape>> = Vec::with_capacity(segments + 1);
4136 for b in 0..=segments {
4137 let theta = if b == segments {
4138 total
4139 } else {
4140 theta_at(b, 0.0)
4141 };
4142 let mut set = Vec::with_capacity(count);
4143 for p in &starts {
4144 set.push(ogeom_algo::make_vertex(model, screw(*p, theta)?).shape);
4145 }
4146 corners.push(set);
4147 }
4148 let mut bottoms = Vec::with_capacity(count);
4149 let mut tops = Vec::with_capacity(count);
4150 let mut held_tops: Vec<Option<Shape>> = vec![None; count];
4153 for seg in 0..segments {
4154 #[allow(clippy::cast_precision_loss)]
4155 let hint = screw(centre, theta_at(seg, (PER_SEGMENT / 2) as f64))?;
4156 let mut first_rail: Option<Shape> = None;
4157 let mut prev_rail: Option<Shape> = None;
4158 for ei in 0..count {
4159 let skin = skin_of(ei, seg)?;
4160 let next = (ei + 1) % count;
4161 let last_rail = if ei + 1 == count {
4162 first_rail.clone()
4163 } else {
4164 None
4165 };
4166 let (from, to) = (&corners[seg], &corners[seg + 1]);
4167 let strip = skinned_strip(
4168 model,
4169 &skin,
4170 (&from[ei], &from[next], &to[ei], &to[next]),
4171 [
4172 held_tops[ei].as_ref(),
4173 None,
4174 prev_rail.as_ref(),
4175 last_rail.as_ref(),
4176 ],
4177 hint,
4178 hole,
4179 tolerance,
4180 tol,
4181 )?;
4182 if ei == 0 {
4183 first_rail = Some(strip.rail0.clone());
4184 }
4185 prev_rail = Some(strip.rail1.clone());
4186 faces.push(strip.face.clone());
4187 if seg == 0 {
4188 bottoms.push(strip.bottom.clone());
4189 }
4190 if seg + 1 == segments {
4191 tops.push(strip.top.clone());
4192 }
4193 held_tops[ei] = Some(strip.top);
4194 }
4195 }
4196 cap_loops[0].push(bottoms);
4197 cap_loops[1].push(tops);
4198 }
4199
4200 for (end, loops) in cap_loops.iter().enumerate() {
4203 let theta = if end == 0 { 0.0 } else { total };
4204 let centre = centre_of(model, profile, tol)?;
4205 let at = screw(centre, theta)?;
4206 let normal = {
4209 let (sin, cos) = (sense * theta).sin_cos();
4210 let along = z * n.dot(z);
4211 let square = n - along;
4212 let turned = along + square * cos + z.cross(square) * sin;
4213 let forward = if turned.dot(travel(at)) >= 0.0 {
4214 turned
4215 } else {
4216 -turned
4217 };
4218 if end == 0 { -forward } else { forward }
4219 };
4220 let cap_plane = Plane::through(at, Direction::new(normal, tol)?);
4221 let mut reach = 1.0_f64;
4222 for edges in loops {
4223 for edge in edges {
4224 let (curve, range) = spine_curve_of(model, edge)?;
4225 for k in 0..8 {
4226 let p =
4227 curve.point_at(range.0 + (range.1 - range.0) * f64::from(k) / 8.0, tol)?;
4228 reach = reach.max(p.distance(at) * 2.0);
4229 }
4230 }
4231 }
4232 let surface: SurfaceGeometry =
4233 PlaneSurface::over(cap_plane, (-reach, reach), (-reach, reach))?.into();
4234 let mut wires = Vec::with_capacity(loops.len());
4237 for (li, edges) in loops.iter().enumerate() {
4238 let ring = walked_about(model, edges, normal, li == 0, tol)?;
4239 wires.push(ogeom_algo::make_wire(model, &ring, tol)?.shape);
4240 }
4241 let face = ogeom_algo::make_face(model, surface, &wires, tol)?.shape;
4242 let cap_id = {
4243 let Some(ogeom_topo::NodeData::Face(data)) = model.node(&face).map(|n| n.data()) else {
4244 ogeom_bail!(Construction, "the cap holds no face data");
4245 };
4246 data.surface
4247 };
4248 let frame = cap_plane.frame();
4249 for edges in loops {
4250 for edge in edges {
4251 let (curve, range) = spine_curve_of(model, edge)?;
4252 let ogeom_geom::Curve::BSpline(bs) = &curve else {
4253 ogeom_bail!(Construction, "a swept ring is not a spline");
4254 };
4255 let control2: Vec<Point2> = bs
4256 .control_points()
4257 .iter()
4258 .map(|w| {
4259 let local = frame.to_local(w.point());
4260 Point2::new(local.x, local.y)
4261 })
4262 .collect();
4263 let pcurve: ogeom_geom::PlanarCurve =
4264 ogeom_geom::BSpline2d::new(bs.knots().clone(), control2, tol)?.into();
4265 ogeom_algo::attach_pcurve(
4266 model,
4267 edge,
4268 pcurve,
4269 cap_id,
4270 ogeom_topo::Location::identity(),
4271 range,
4272 )?;
4273 }
4274 }
4275 faces.push(face);
4276 }
4277
4278 let sewn = sew(model, &faces, tol)?;
4279 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
4280 ogeom_bail!(Construction, "the helical sweep did not close");
4281 }
4282 let solid = make_solid(model, &sewn.shells)?.shape;
4283 let mut history = History::new();
4284 history.generate(profile, solid.clone());
4285 Ok(Built::new(solid, history))
4286}
4287
4288pub fn make_revolution_until(
4307 model: &mut Model,
4308 profile: &Shape,
4309 axis: ogeom_math::Axis,
4310 limit: &Shape,
4311 tol: Tolerances,
4312) -> OgeomResult<Built> {
4313 use ogeom_geom::Surface as _;
4314 if model.kind_of(profile)? != ShapeType::Face {
4315 ogeom_bail!(
4316 Construction,
4317 "a revolution up to a face revolves a planar face"
4318 );
4319 }
4320 if model.kind_of(limit)? != ShapeType::Face {
4321 ogeom_bail!(Construction, "a revolution stops on a face");
4322 }
4323 let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
4324 ogeom_bail!(
4325 Construction,
4326 "a revolution up to a face revolves a planar face"
4327 );
4328 };
4329 let z = axis.direction.vector();
4330 if plane.normal().vector().dot(z).abs() > tol.angular()
4331 || plane.distance_to(axis.location) > tol.confusion() * 100.0
4332 {
4333 ogeom_bail!(
4334 Construction,
4335 "the profile's plane does not hold the axis; a revolution up to a \
4336 face turns a profile about an axis in its own plane"
4337 );
4338 }
4339 let (surface, flip) = {
4341 let Some(data) = model.node(limit).and_then(|n| n.data().as_face()) else {
4342 ogeom_bail!(Construction, "the limit holds no face data");
4343 };
4344 let Some(surface) = model.geometry().surface(data.surface) else {
4345 ogeom_bail!(Dangling, "the limit's surface is not in this model");
4346 };
4347 (
4348 surface
4349 .clone()
4350 .transformed(&limit.transform(model.datums())?, tol)?,
4351 limit.orientation() == ogeom_topo::Orientation::Reversed,
4352 )
4353 };
4354 let side = |p: Point| -> OgeomResult<f64> {
4355 let foot = ogeom_algo::project_on_surface(&surface, p, 16, tol)?;
4356 let (u, v) = foot.parameters;
4357 let n = surface.normal_at(u, v, tol)?.vector();
4358 let s = (p - foot.point).dot(n);
4359 Ok(if flip { -s } else { s })
4360 };
4361 let turn_of = |p: Point, theta: f64| Transform::rotation(axis, theta).apply(p);
4364 let mut rings = Vec::new();
4365 for wire in explore(model, profile, Filter::OfType(ShapeType::Wire))? {
4366 rings.extend(sample_wire(model, &wire, 64, tol)?);
4367 }
4368 let start_side = side(centre_of(model, profile, tol)?)?.signum();
4369 const STEPS: u32 = 360;
4370 for p in &rings {
4371 let mut met: Option<f64> = None;
4372 for k in 1..=STEPS {
4373 let theta = core::f64::consts::TAU * f64::from(k) / f64::from(STEPS);
4374 let here = side(turn_of(*p, theta))?;
4375 match met {
4376 None if here.signum() != start_side && here.abs() > tol.confusion() => {
4377 met = Some(theta);
4378 }
4379 Some(first)
4380 if theta <= core::f64::consts::PI
4381 && here.signum() == start_side
4382 && here.abs() > tol.confusion() =>
4383 {
4384 ogeom_bail!(
4385 Construction,
4386 "the circle through {p:?} meets the limit at {first} and \
4387 crosses back within half a turn; a revolution up to it \
4388 is not built there"
4389 );
4390 }
4391 _ => {}
4392 }
4393 }
4394 match met {
4395 None => ogeom_bail!(
4396 Construction,
4397 "the circle through {p:?} never meets the limit's surface"
4398 ),
4399 Some(first) if first > core::f64::consts::PI => ogeom_bail!(
4400 Construction,
4401 "the circle through {p:?} meets the limit only past half a turn"
4402 ),
4403 _ => {}
4404 }
4405 }
4406 let half = ogeom_algo::make_revolution(model, profile, axis, core::f64::consts::PI, tol)?;
4407 let inside = centre_of(model, profile, tol)?;
4408 let bound = ogeom_algo::make_half_space(model, limit, inside, tol)?.shape;
4409 let mut built = ogeom_bool::common(model, &half.shape, &bound, tol)?;
4410 built.history.generate(profile, built.shape.clone());
4411 Ok(built)
4412}
4413
4414fn centre_of(model: &Model, profile: &Shape, tol: Tolerances) -> OgeomResult<Point> {
4416 let Some(wire) = explore(model, profile, Filter::OfType(ShapeType::Wire))?
4417 .into_iter()
4418 .next()
4419 else {
4420 ogeom_bail!(Construction, "the profile has no loop");
4421 };
4422 let samples = sample_wire(model, &wire, 32, tol)?;
4423 #[allow(clippy::cast_precision_loss)]
4424 let n = samples.len() as f64;
4425 let sum = samples
4426 .iter()
4427 .fold(Vector::new(0.0, 0.0, 0.0), |acc, p| acc + p.to_vector());
4428 Ok(Point::from_vector(sum / n))
4429}
4430
4431fn exact_legs(
4440 model: &mut Model,
4441 profile: &Shape,
4442 spine: &Shape,
4443 frenet: bool,
4444 tol: Tolerances,
4445) -> OgeomResult<Option<Built>> {
4446 use ogeom_geom::{Curve, Curve3d as _};
4447 let edges: Vec<Shape> = match model.kind_of(spine)? {
4448 ShapeType::Edge => vec![spine.clone()],
4449 ShapeType::Wire => model.ordered_children_of(spine)?,
4450 _ => return Ok(None),
4451 };
4452 let solid = match model.kind_of(profile)? {
4453 ShapeType::Face => true,
4454 ShapeType::Wire => false,
4455 _ => return Ok(None),
4456 };
4457 if edges.is_empty() || (!solid && edges.len() > 1) {
4458 return Ok(None);
4459 }
4460 struct Leg {
4463 start: Point,
4464 heading: (Vector, Vector),
4465 motion: Transform,
4466 along: LegKind,
4467 }
4468 enum LegKind {
4469 Line(Vector),
4470 Arc(ogeom_math::Axis, f64),
4471 }
4472 let mut legs = Vec::with_capacity(edges.len());
4473 for edge in &edges {
4474 let (curve, range) = spine_curve_of(model, edge)?;
4475 let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
4476 let (t0, t1) = if reversed { (range.1, range.0) } else { range };
4477 let (a, b) = (curve.point_at(t0, tol)?, curve.point_at(t1, tol)?);
4478 let sense = if reversed { -1.0 } else { 1.0 };
4479 let heading = (curve.d1_at(t0, tol)? * sense, curve.d1_at(t1, tol)? * sense);
4480 let basis = match &curve {
4481 Curve::Trimmed(t) => t.basis().clone(),
4482 other => other.clone(),
4483 };
4484 let (motion, along) = match basis {
4485 Curve::Line(_) => {
4486 if frenet {
4487 return Ok(None);
4488 }
4489 (Transform::translation(b - a), LegKind::Line(b - a))
4490 }
4491 Curve::Circle(c) => {
4492 let frame = c.circle().frame();
4493 let turn = (a - frame.origin()).cross(heading.0);
4495 let direction = if turn.dot(frame.z().vector()) > 0.0 {
4496 frame.z()
4497 } else {
4498 -frame.z()
4499 };
4500 let axis = ogeom_math::Axis {
4501 location: frame.origin(),
4502 direction,
4503 };
4504 let angle = (t1 - t0).abs();
4505 (Transform::rotation(axis, angle), LegKind::Arc(axis, angle))
4506 }
4507 _ => return Ok(None),
4508 };
4509 legs.push(Leg {
4510 start: a,
4511 heading,
4512 motion,
4513 along,
4514 });
4515 }
4516 let cornered = legs.windows(2).any(|pair| {
4519 let (x, y) = (pair[0].heading.1, pair[1].heading.0);
4520 x.cross(y).magnitude() > tol.angular() * x.magnitude() * y.magnitude() || x.dot(y) <= 0.0
4521 });
4522 if cornered {
4523 let lines: Option<Vec<(Point, Vector)>> = legs
4524 .iter()
4525 .map(|leg| match leg.along {
4526 LegKind::Line(v) => Some((leg.start, v)),
4527 LegKind::Arc(..) => None,
4528 })
4529 .collect();
4530 return match lines {
4531 Some(lines) if solid && !frenet => mitred_lines(model, profile, spine, &lines, tol),
4532 _ => Ok(None),
4533 };
4534 }
4535 let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
4537 return Ok(None);
4538 };
4539 let t0 = legs[0].heading.0;
4540 if plane.normal().vector().cross(t0).magnitude() > tol.angular() * t0.magnitude()
4541 || plane.distance_to(legs[0].start) > tol.confusion() * 100.0
4542 {
4543 return Ok(None);
4544 }
4545
4546 let mut joined: Vec<Shape> = Vec::new();
4547 let mut carried = Transform::IDENTITY;
4548 for leg in &legs {
4549 let heading = carried.apply_vector(legs[0].heading.0);
4552 let section = realized_profile_wound(model, profile, &carried, Some(heading), tol)?;
4553 let piece = match leg.along {
4554 LegKind::Line(v) => ogeom_algo::make_prism(model, §ion, v, tol)?.shape,
4555 LegKind::Arc(axis, angle) => {
4556 ogeom_algo::make_revolution(model, §ion, axis, angle, tol)?.shape
4557 }
4558 };
4559 carried = leg.motion * carried;
4560 joined.push(piece);
4561 }
4562 let result = fuse_in_order(model, joined, tol)?;
4563 Ok(result.map(|shape| {
4564 let mut history = History::new();
4565 history.generate(spine, shape.clone());
4566 history.generate(profile, shape.clone());
4567 Built::new(shape, history)
4568 }))
4569}
4570
4571fn mitred_lines(
4583 model: &mut Model,
4584 profile: &Shape,
4585 spine: &Shape,
4586 legs: &[(Point, Vector)],
4587 tol: Tolerances,
4588) -> OgeomResult<Option<Built>> {
4589 if let (Some(first), Some(last)) = (legs.first(), legs.last())
4590 && first.0.distance(last.0 + last.1) <= tol.confusion() * 100.0
4591 {
4592 return Ok(None);
4593 }
4594 let bounds = ogeom_algo::shape_bounds(model, profile, tol)?;
4595 let (Some(low), Some(high)) = (bounds.low(), bounds.high()) else {
4596 return Ok(None);
4597 };
4598 let width = low.distance(high);
4599 let unit = |v: Vector| v / v.magnitude();
4600 let mut mitres: Vec<(Point, Vector)> = Vec::with_capacity(legs.len().saturating_sub(1));
4603 for pair in legs.windows(2) {
4604 let (d0, d1) = (unit(pair[0].1), unit(pair[1].1));
4605 if d0.dot(d1) < -0.7 {
4608 return Ok(None);
4609 }
4610 mitres.push((pair[1].0, unit(d0 + d1)));
4611 }
4612 let reach = width * 2.0;
4614 for (i, (_, v)) in legs.iter().enumerate() {
4615 let corners = f64::from(u8::from(i > 0) + u8::from(i + 1 < legs.len()));
4616 if v.magnitude() <= reach * corners * 0.5 {
4617 return Ok(None);
4618 }
4619 }
4620
4621 let mut carried = Transform::IDENTITY;
4622 let mut joined: Vec<Shape> = Vec::new();
4623 for (i, &(_, v)) in legs.iter().enumerate() {
4624 let d = unit(v);
4625 let before = if i > 0 { reach } else { 0.0 };
4626 let after = if i + 1 < legs.len() { reach } else { 0.0 };
4627 let section = realized_profile_wound(
4628 model,
4629 profile,
4630 &(Transform::translation(-d * before) * carried),
4631 Some(d),
4632 tol,
4633 )?;
4634 let mut piece =
4635 ogeom_algo::make_prism(model, §ion, v + d * (before + after), tol)?.shape;
4636 for (at, normal, keep_ahead) in [
4638 (i > 0).then(|| (mitres[i - 1].0, mitres[i - 1].1, true)),
4639 (i + 1 < legs.len()).then(|| (mitres[i].0, mitres[i].1, false)),
4640 ]
4641 .into_iter()
4642 .flatten()
4643 {
4644 let plane = Plane::through(at, Direction::new(normal, tol)?);
4645 let face = ogeom_algo::make_natural_face(
4646 model,
4647 SurfaceGeometry::Plane(ogeom_geom::PlaneSurface::over(
4648 plane,
4649 (-reach * 4.0 - width, reach * 4.0 + width),
4650 (-reach * 4.0 - width, reach * 4.0 + width),
4651 )?),
4652 )?
4653 .shape;
4654 let side = if keep_ahead { normal } else { -normal };
4655 let half = ogeom_algo::make_half_space(model, &face, at + side * width, tol)?.shape;
4656 piece = ogeom_bool::common(model, &piece, &half, tol)?.shape;
4657 }
4658 carried = Transform::translation(v) * carried;
4661 if let Some(&(corner, next)) = legs.get(i + 1) {
4662 let n = unit(next);
4663 let axis = d.cross(n);
4664 if axis.magnitude() > tol.angular() {
4665 let angle = d.dot(n).clamp(-1.0, 1.0).acos();
4666 let rotation = Transform::rotation(
4667 ogeom_math::Axis {
4668 location: corner,
4669 direction: Direction::new(axis, tol)?,
4670 },
4671 angle,
4672 );
4673 carried = rotation * carried;
4674 }
4675 }
4676 joined.push(piece);
4677 }
4678 let result = fuse_in_order(model, joined, tol)?;
4679 Ok(result.map(|shape| {
4680 let mut history = History::new();
4681 history.generate(spine, shape.clone());
4682 history.generate(profile, shape.clone());
4683 Built::new(shape, history)
4684 }))
4685}
4686
4687pub fn make_pipe_shell(
4729 model: &mut Model,
4730 profile: &Shape,
4731 spine: &Shape,
4732 frenet: bool,
4733 tolerance: f64,
4734 tol: Tolerances,
4735) -> OgeomResult<Built> {
4736 let law = if frenet {
4737 PipeLaw::Frenet
4738 } else {
4739 PipeLaw::RotationMinimizing
4740 };
4741 make_pipe_shell_with(
4742 model,
4743 profile,
4744 spine,
4745 &law,
4746 PipeCorners::Mitre,
4747 tolerance,
4748 tol,
4749 )
4750}
4751
4752#[derive(Debug, Clone, Copy)]
4754pub enum PipeLaw<'a> {
4755 RotationMinimizing,
4758 Frenet,
4760 Auxiliary {
4764 guide: &'a Shape,
4766 },
4767 Binormal(Direction),
4770 Fixed,
4773}
4774
4775#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
4777pub enum PipeCorners {
4778 #[default]
4780 Mitre,
4781 Extended,
4785 Round,
4789}
4790
4791pub fn make_pipe_shell_law(
4798 model: &mut Model,
4799 profile: &Shape,
4800 spine: &Shape,
4801 law: PipeLaw<'_>,
4802 tolerance: f64,
4803 tol: Tolerances,
4804) -> OgeomResult<Built> {
4805 make_pipe_shell_with(
4806 model,
4807 profile,
4808 spine,
4809 &law,
4810 PipeCorners::Mitre,
4811 tolerance,
4812 tol,
4813 )
4814}
4815
4816#[allow(clippy::too_many_arguments)]
4833pub fn make_pipe_shell_with(
4834 model: &mut Model,
4835 profile: &Shape,
4836 spine: &Shape,
4837 law: &PipeLaw<'_>,
4838 corners: PipeCorners,
4839 tolerance: f64,
4840 tol: Tolerances,
4841) -> OgeomResult<Built> {
4842 if matches!(law, PipeLaw::Fixed) {
4845 return crate::fixed::fixed_pipe(model, profile, spine, tol);
4846 }
4847 if corners != PipeCorners::Mitre
4848 && let Some(legs) = straight_legs(model, spine, tol)?
4849 && legs.len() > 1
4850 {
4851 if !matches!(law, PipeLaw::RotationMinimizing) {
4852 ogeom_bail!(
4853 Construction,
4854 "extended and round corners turn the section with the \
4855 rotation-minimizing frame"
4856 );
4857 }
4858 if model.kind_of(profile)? != ShapeType::Face {
4859 ogeom_bail!(Construction, "extended and round corners sweep a face");
4860 }
4861 return cornered_lines(model, profile, spine, &legs, corners, tol);
4862 }
4863 if corners != PipeCorners::Mitre && has_sharp_corner(model, spine, tol)? {
4864 ogeom_bail!(
4865 Construction,
4866 "extended and round corners are built along a spine of straight legs"
4867 );
4868 }
4869 pipe_shell_law(model, profile, spine, law, tolerance, tol)
4870}
4871
4872fn straight_legs(
4875 model: &Model,
4876 spine: &Shape,
4877 tol: Tolerances,
4878) -> OgeomResult<Option<Vec<(Point, Vector)>>> {
4879 use ogeom_geom::Curve;
4880 let edges: Vec<Shape> = match model.kind_of(spine)? {
4881 ShapeType::Edge => vec![spine.clone()],
4882 ShapeType::Wire => model.ordered_children_of(spine)?,
4883 _ => return Ok(None),
4884 };
4885 let mut legs = Vec::with_capacity(edges.len());
4886 for edge in &edges {
4887 let (curve, range) = spine_curve_of(model, edge)?;
4888 let basis = match &curve {
4889 Curve::Trimmed(t) => t.basis().clone(),
4890 other => other.clone(),
4891 };
4892 if !matches!(basis, Curve::Line(_)) {
4893 return Ok(None);
4894 }
4895 let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
4896 let (t0, t1) = if reversed { (range.1, range.0) } else { range };
4897 let (a, b) = (curve.point_at(t0, tol)?, curve.point_at(t1, tol)?);
4898 legs.push((a, b - a));
4899 }
4900 Ok(Some(legs))
4901}
4902
4903pub(crate) fn has_sharp_corner(model: &Model, spine: &Shape, tol: Tolerances) -> OgeomResult<bool> {
4905 let stations = shell_stations(model, spine, tol)?;
4906 Ok((0..stations.len() - 1).any(|i| {
4907 stations[i].at.distance(stations[i + 1].at) <= tol.confusion()
4908 && (stations[i]
4909 .tangent
4910 .cross(stations[i + 1].tangent)
4911 .magnitude()
4912 > tol.angular()
4913 || stations[i].tangent.dot(stations[i + 1].tangent) < 0.0)
4914 }))
4915}
4916
4917fn cornered_lines(
4922 model: &mut Model,
4923 profile: &Shape,
4924 spine: &Shape,
4925 legs: &[(Point, Vector)],
4926 corners: PipeCorners,
4927 tol: Tolerances,
4928) -> OgeomResult<Built> {
4929 if let (Some(first), Some(last)) = (legs.first(), legs.last())
4930 && first.0.distance(last.0 + last.1) <= tol.confusion() * 100.0
4931 {
4932 ogeom_bail!(
4933 Construction,
4934 "extended and round corners are built along an open spine"
4935 );
4936 }
4937 let unit = |v: Vector| v / v.magnitude();
4938 let Some(outer) = explore(model, profile, Filter::OfType(ShapeType::Wire))?
4939 .into_iter()
4940 .next()
4941 else {
4942 ogeom_bail!(Construction, "the profile has no loop");
4943 };
4944 let samples = sample_wire(model, &outer, 256, tol)?;
4945 let mut carried: Vec<Transform> = Vec::with_capacity(legs.len());
4949 let mut turns: Vec<Option<(ogeom_math::Axis, f64)>> = Vec::with_capacity(legs.len());
4950 let mut motion = Transform::IDENTITY;
4951 for (i, &(_, v)) in legs.iter().enumerate() {
4952 carried.push(motion);
4953 motion = Transform::translation(v) * motion;
4954 let turn = match legs.get(i + 1) {
4955 Some(&(corner, next)) => {
4956 let (d, n) = (unit(v), unit(next));
4957 if d.dot(n) < -0.7 {
4958 ogeom_bail!(
4959 Construction,
4960 "the spine all but doubles back at a corner; no corner \
4961 of that kind turns it"
4962 );
4963 }
4964 let axis = d.cross(n);
4965 if axis.magnitude() > tol.angular() {
4966 let about = ogeom_math::Axis {
4967 location: corner,
4968 direction: Direction::new(axis, tol)?,
4969 };
4970 let angle = d.dot(n).clamp(-1.0, 1.0).acos();
4971 motion = Transform::rotation(about, angle) * motion;
4972 Some((about, angle))
4973 } else {
4974 None
4975 }
4976 }
4977 None => None,
4978 };
4979 turns.push(turn);
4980 }
4981 let reach_past = |section: &Transform, corner: Point, along: Vector, turn: f64| {
4984 let far = samples
4985 .iter()
4986 .map(|p| (section.apply(*p) - corner).dot(along))
4987 .fold(0.0_f64, f64::max);
4988 far / turn.sin().max(1e-3)
4989 };
4990 let mut joined: Vec<Shape> = Vec::new();
4991 for (i, &(_, v)) in legs.iter().enumerate() {
4992 let d = unit(v);
4993 let (mut before, mut after) = (0.0, 0.0);
4994 if corners == PipeCorners::Extended {
4995 if i > 0
4996 && let Some((_, angle)) = turns[i - 1]
4997 {
4998 let at_corner = Transform::translation(legs[i - 1].1) * carried[i - 1];
5000 before = reach_past(&at_corner, legs[i].0, -d, angle);
5001 }
5002 if let Some((_, angle)) = turns[i] {
5003 after = reach_past(&carried[i + 1], legs[i + 1].0, d, angle);
5005 }
5006 }
5007 let section = realized_profile_wound(
5008 model,
5009 profile,
5010 &(Transform::translation(-d * before) * carried[i]),
5011 Some(d),
5012 tol,
5013 )?;
5014 let mut piece =
5015 ogeom_algo::make_prism(model, §ion, v + d * (before + after), tol)?.shape;
5016 if corners == PipeCorners::Round
5017 && let Some((axis, angle)) = turns[i]
5018 {
5019 let end = realized_profile_wound(
5020 model,
5021 profile,
5022 &(Transform::translation(v) * carried[i]),
5023 Some(d),
5024 tol,
5025 )?;
5026 let next = unit(legs[i + 1].1);
5030 let inward = next - d * next.dot(d);
5031 let outer = outside_half(model, &end, axis.location, d, -inward, tol)?;
5032 let bend = ogeom_algo::make_revolution(model, &outer, axis, angle, tol)?.shape;
5033 piece = ogeom_bool::fuse(model, &piece, &bend, tol)?.shape;
5034 }
5035 joined.push(piece);
5036 }
5037 let result = fuse_in_order(model, joined, tol)?;
5038 let Some(shape) = result else {
5039 ogeom_bail!(Construction, "the spine has no leg");
5040 };
5041 let mut history = History::new();
5042 history.generate(spine, shape.clone());
5043 history.generate(profile, shape.clone());
5044 Ok(Built::new(shape, history))
5045}
5046
5047fn outside_half(
5052 model: &mut Model,
5053 section: &Shape,
5054 at: Point,
5055 normal: Vector,
5056 outward: Vector,
5057 tol: Tolerances,
5058) -> OgeomResult<Shape> {
5059 let width = {
5060 let bounds = ogeom_algo::shape_bounds(model, section, tol)?;
5061 match (bounds.low(), bounds.high()) {
5062 (Some(a), Some(b)) => a.distance(b),
5063 _ => ogeom_bail!(Construction, "the section has no extent"),
5064 }
5065 };
5066 let n = normal / normal.magnitude();
5067 let slab = ogeom_algo::make_prism(model, section, n * width, tol)?.shape;
5068 let cut = Plane::through(at, Direction::new(outward, tol)?);
5069 let face = ogeom_algo::make_natural_face(
5070 model,
5071 SurfaceGeometry::Plane(ogeom_geom::PlaneSurface::over(
5072 cut,
5073 (-width * 4.0, width * 4.0),
5074 (-width * 4.0, width * 4.0),
5075 )?),
5076 )?
5077 .shape;
5078 let side = outward / outward.magnitude() * width;
5079 let half = ogeom_algo::make_half_space(model, &face, at + side, tol)?.shape;
5080 let kept = ogeom_bool::common(model, &slab, &half, tol)?.shape;
5081 for face in explore(model, &kept, Filter::OfType(ShapeType::Face))? {
5082 let Ok((point, facing)) = ogeom_algo::face_normal(model, &face, tol) else {
5083 continue;
5084 };
5085 if (point - at).dot(n).abs() <= tol.confusion() * 100.0
5086 && facing.cross(n).magnitude() <= tol.angular() * 10.0
5087 {
5088 return Ok(if facing.dot(n) > 0.0 {
5090 face
5091 } else {
5092 face.reversed()
5093 });
5094 }
5095 }
5096 ogeom_bail!(
5097 Construction,
5098 "the section has no part outside the corner's axis to turn"
5099 )
5100}
5101
5102fn densified(
5107 model: &Model,
5108 spine: &Shape,
5109 stations: Vec<SpineStation>,
5110 law: &PipeLaw<'_>,
5111 tolerance: f64,
5112 tol: Tolerances,
5113) -> OgeomResult<Vec<SpineStation>> {
5114 const MOST: f64 = 0.05;
5115 let edges: Vec<Shape> = match model.kind_of(spine)? {
5116 ShapeType::Edge => vec![spine.clone()],
5117 ShapeType::Wire => model.ordered_children_of(spine)?,
5118 _ => return Ok(stations),
5119 };
5120 let mut curves = Vec::with_capacity(edges.len());
5121 for edge in &edges {
5122 curves.push(spine_curve_of(model, edge)?.0);
5123 }
5124 let mut stations = stations;
5125 for _ in 0..12 {
5126 let normals = law_normals(model, &stations, law, tolerance, tol)?;
5127 let mut out: Vec<SpineStation> = Vec::with_capacity(stations.len() * 2);
5128 let mut added = false;
5129 for i in 0..stations.len() {
5130 out.push(stations[i]);
5131 let Some(next) = stations.get(i + 1) else {
5132 continue;
5133 };
5134 let here = stations[i];
5135 if here.edge != next.edge || here.at.distance(next.at) <= tol.confusion() {
5136 continue;
5137 }
5138 let turn = normals[i].dot(normals[i + 1]).clamp(-1.0, 1.0).acos();
5139 if turn <= MOST {
5140 continue;
5141 }
5142 let curve = &curves[here.edge];
5143 let t = f64::midpoint(here.t, next.t);
5144 let d = curve.d1_at(t, tol)?;
5145 let sense = if curve.d1_at(here.t, tol)?.dot(here.tangent) >= 0.0 {
5146 1.0
5147 } else {
5148 -1.0
5149 };
5150 out.push(SpineStation {
5151 at: curve.point_at(t, tol)?,
5152 tangent: d * sense / d.magnitude(),
5153 edge: here.edge,
5154 t,
5155 });
5156 added = true;
5157 }
5158 stations = out;
5159 if !added {
5160 break;
5161 }
5162 if stations.len() > MOST_STATIONS {
5166 ogeom_bail!(
5167 NotDone,
5168 "the frame law turns faster than {MOST_STATIONS} sections can follow; \
5169 it jumps somewhere along the spine"
5170 );
5171 }
5172 }
5173 Ok(stations)
5174}
5175
5176const MOST_STATIONS: usize = 2048;
5178
5179pub(crate) fn station_frame(
5182 station: &SpineStation,
5183 normal: Vector,
5184 tol: Tolerances,
5185) -> OgeomResult<Frame> {
5186 Frame::new(
5187 station.at,
5188 Direction::new(station.tangent, tol)?,
5189 Direction::new(normal, tol)?,
5190 tol,
5191 )
5192}
5193
5194fn moved_ring(
5198 model: &mut Model,
5199 ring: &Shape,
5200 motion: &Transform,
5201 tol: Tolerances,
5202) -> OgeomResult<Shape> {
5203 let edges = model.ordered_children_of(ring)?;
5204 let mut vertices: Vec<(Point, Shape)> = Vec::new();
5205 let mut vertex = |model: &mut Model, at: Point| -> Shape {
5206 if let Some((_, v)) = vertices
5207 .iter()
5208 .find(|(p, _)| p.distance(at) <= tol.confusion() * 10.0)
5209 {
5210 return v.clone();
5211 }
5212 let v = ogeom_algo::make_vertex(model, at).shape;
5213 vertices.push((at, v.clone()));
5214 v
5215 };
5216 let mut moved = Vec::with_capacity(edges.len());
5217 for edge in &edges {
5218 let (curve, range) = spine_curve_of(model, edge)?;
5219 let placement = edge.transform(model.datums())?;
5220 let curve = curve.transformed(&(*motion * placement), tol)?;
5221 let (a, b) = (curve.point_at(range.0, tol)?, curve.point_at(range.1, tol)?);
5222 let (va, vb) = (vertex(model, a), vertex(model, b));
5223 let built = ogeom_algo::make_edge_between(model, curve, range, &va, &vb, tol)?.shape;
5224 moved.push(if edge.orientation() == ogeom_topo::Orientation::Reversed {
5225 built.reversed()
5226 } else {
5227 built
5228 });
5229 }
5230 Ok(ogeom_algo::make_wire(model, &moved, tol)?.shape)
5231}
5232
5233#[allow(clippy::too_many_arguments)]
5237fn law_loft(
5238 model: &mut Model,
5239 profile: &Shape,
5240 spine: &Shape,
5241 stations: &[SpineStation],
5242 law: &PipeLaw<'_>,
5243 tolerance: f64,
5244 tol: Tolerances,
5245) -> OgeomResult<Built> {
5246 let rings: Vec<Shape> = match model.kind_of(profile)? {
5247 ShapeType::Face => model.ordered_children_of(profile)?,
5248 ShapeType::Wire => vec![profile.clone()],
5249 _ => ogeom_bail!(Construction, "a pipe sweeps a planar face or wire"),
5250 };
5251 if rings.len() != 1 {
5252 ogeom_bail!(
5253 Construction,
5254 "an auxiliary or binormal law sweeps a profile with no hole"
5255 );
5256 }
5257 let normals = law_normals(model, stations, law, tolerance, tol)?;
5258 let start = station_frame(&stations[0], normals[0], tol)?;
5259 let mut kept: Vec<SpineStation> = Vec::with_capacity(stations.len());
5260 let mut kept_normals: Vec<Vector> = Vec::with_capacity(stations.len());
5261 let mut sections: Vec<Shape> = Vec::with_capacity(stations.len());
5262 for (station, normal) in stations.iter().zip(&normals) {
5263 if kept
5264 .last()
5265 .is_some_and(|p| p.at.distance(station.at) <= tol.confusion())
5266 {
5267 continue;
5268 }
5269 kept.push(*station);
5270 kept_normals.push(*normal);
5271 let frame = station_frame(station, *normal, tol)?;
5272 let motion = Transform::from_frame(&frame) * Transform::to_frame(&start);
5273 sections.push(moved_ring(model, &rings[0], &motion, tol)?);
5274 }
5275 let walk = SpineWalk {
5278 curves: walk_curves(model, spine)?,
5279 stations: &kept,
5280 normals: &kept_normals,
5281 };
5282 let held: std::cell::RefCell<ogeom_core::FastMap<u64, Transform>> =
5283 std::cell::RefCell::default();
5284 let guide = guide_curves(model, law)?;
5285 let motion = |s: f64| -> OgeomResult<Transform> {
5286 if let Some(m) = held.borrow().get(&s.to_bits()) {
5287 return Ok(*m);
5288 }
5289 let (at, tangent, _) = walk.frame_at(s, (0, kept.len() - 1), tol)?;
5290 let here = SpineStation {
5291 at,
5292 tangent,
5293 ..kept[0]
5294 };
5295 let normal = law_normals_on(&guide, &[here], law, tolerance, tol)?[0];
5296 let frame = station_frame(&here, normal, tol)?;
5297 let m = Transform::from_frame(&frame) * Transform::to_frame(&start);
5298 held.borrow_mut().insert(s.to_bits(), m);
5299 Ok(m)
5300 };
5301 let mut built = loft_skinned_along(model, §ions, Some(&motion), tolerance, tol)?;
5302 built.history.generate(spine, built.shape.clone());
5303 built.history.generate(profile, built.shape.clone());
5304 Ok(built)
5305}
5306
5307pub fn make_pipe_sections(
5329 model: &mut Model,
5330 sections: &[Shape],
5331 spine: &Shape,
5332 frenet: bool,
5333 tolerance: f64,
5334 tol: Tolerances,
5335) -> OgeomResult<Built> {
5336 const AROUND: usize = 64;
5337 if sections.len() < 2 {
5338 ogeom_bail!(
5339 Construction,
5340 "a multisection pipe needs at least two sections"
5341 );
5342 }
5343 let law = if frenet {
5344 PipeLaw::Frenet
5345 } else {
5346 PipeLaw::RotationMinimizing
5347 };
5348 let stations = shell_stations(model, spine, tol)?;
5349 let stations = evenly(model, spine, stations, 24, false, tol)?;
5350 let normals = law_normals(model, &stations, &law, tolerance, tol)?;
5351 let mut run = vec![0.0_f64];
5353 for pair in stations.windows(2) {
5354 let held = run[run.len() - 1];
5355 run.push(held + pair[0].at.distance(pair[1].at));
5356 }
5357 let mut placed: Vec<(f64, Section, Vec<Point>)> = Vec::with_capacity(sections.len());
5362 let mut apex: Option<(Point, bool)> = None;
5364 let mut last_ring: Option<Shape> = None;
5366 for (index, section) in sections.iter().enumerate() {
5367 if model.kind_of(section)? == ShapeType::Vertex {
5368 let Some(data) = model.node(section).and_then(|n| n.data().as_vertex()) else {
5369 ogeom_bail!(Dangling, "vertex is not in this model");
5370 };
5371 let point = section.transform(model.datums())?.apply(data.point);
5372 let reach = tolerance.max(tol.confusion() * 1e3);
5373 let (Some(head), Some(tail)) = (stations.first(), stations.last()) else {
5374 ogeom_bail!(Construction, "the spine has no stations");
5375 };
5376 let at_end = if index + 1 == sections.len() && point.distance(tail.at) <= reach {
5377 true
5378 } else if index == 0 && point.distance(head.at) <= reach {
5379 false
5380 } else {
5381 ogeom_bail!(
5382 Construction,
5383 "a point section stands first or last, where the spine \
5384 starts or ends"
5385 );
5386 };
5387 apex = Some((point, at_end));
5388 let along = if at_end { run[run.len() - 1] } else { 0.0 };
5391 placed.push((
5392 along,
5393 Section::Point(Point::ORIGIN),
5394 vec![Point::ORIGIN; AROUND],
5395 ));
5396 continue;
5397 }
5398 let ring = match model.kind_of(section)? {
5399 ShapeType::Face => {
5400 let rings = model.ordered_children_of(section)?;
5401 if rings.len() != 1 {
5402 ogeom_bail!(Construction, "a multisection pipe's section has no hole");
5403 }
5404 rings[0].clone()
5405 }
5406 ShapeType::Wire => section.clone(),
5407 _ => ogeom_bail!(
5408 Construction,
5409 "a section is a planar face or wire, or a point at an end"
5410 ),
5411 };
5412 let Some(plane) = ogeom_algo::find_plane(model, &ring, tol)? else {
5413 ogeom_bail!(Construction, "a section is not planar");
5414 };
5415 last_ring = Some(ring.clone());
5416 let side = |p: Point| (p - plane.origin()).dot(plane.normal().vector());
5417 let mut found: Option<(f64, usize, f64)> = None;
5418 for i in 0..stations.len() {
5419 let here = side(stations[i].at);
5420 let crossing = if here.abs() <= tol.confusion() {
5421 Some((run[i], i, 0.0))
5422 } else if let Some(next) = stations.get(i + 1) {
5423 let there = side(next.at);
5424 (here.signum() != there.signum() && there.abs() > tol.confusion()).then(|| {
5425 let f = here / (here - there);
5426 (run[i] + (run[i + 1] - run[i]) * f, i, f)
5427 })
5428 } else {
5429 None
5430 };
5431 if crossing.is_some() {
5432 found = crossing;
5433 break;
5434 }
5435 }
5436 let Some((along, i, f)) = found else {
5437 ogeom_bail!(Construction, "the spine does not cross a section's plane");
5438 };
5439 let j = (i + 1).min(stations.len() - 1);
5441 let at = stations[i].at + (stations[j].at - stations[i].at) * f;
5442 let tangent = stations[i].tangent * (1.0 - f) + stations[j].tangent * f;
5443 let normal = normals[i] * (1.0 - f) + normals[j] * f;
5444 let normal = normal - tangent * normal.dot(tangent) / tangent.dot(tangent);
5445 let frame = Frame::new(
5446 at,
5447 Direction::new(tangent, tol)?,
5448 Direction::new(normal, tol)?,
5449 tol,
5450 )?;
5451 let into = Transform::to_frame(&frame);
5452 let dense: Vec<Point> = dense_wire(model, &ring, tol)?
5453 .iter()
5454 .map(|p| into.apply(*p))
5455 .collect();
5456 let around = match placed.last() {
5457 Some((_, _, previous)) => matched_loop(&dense, AROUND, previous),
5458 None => ArcLoop::new(dense),
5459 };
5460 let samples = fractions(AROUND)[..AROUND]
5461 .iter()
5462 .map(|f| around.at(*f))
5463 .collect();
5464 placed.push((along, Section::Loop(around), samples));
5465 }
5466 for pair in placed.windows(2) {
5467 if pair[1].0 <= pair[0].0 + tol.confusion() {
5468 ogeom_bail!(
5469 Construction,
5470 "the sections stand out of order along the spine"
5471 );
5472 }
5473 }
5474 if let (Some((point, _)), Some(ring), 2) = (apex, &last_ring, sections.len())
5478 && straight_legs(model, spine, tol)?.is_some_and(|legs| legs.len() == 1)
5479 {
5480 let tip = model.add_vertex(VertexData::new(point));
5481 let mut built = make_loft(model, ring, &tip, tol)?;
5482 built.history.generate(spine, built.shape.clone());
5483 for section in sections {
5484 built.history.generate(section, built.shape.clone());
5485 }
5486 return Ok(built);
5487 }
5488 let (first, last) = (placed[0].0, placed[placed.len() - 1].0);
5491 let mut rings: Vec<Shape> = Vec::new();
5492 let mut ring_at = |model: &mut Model, along: f64, frame: &Frame| -> OgeomResult<()> {
5493 let k = placed
5494 .windows(2)
5495 .position(|w| along <= w[1].0 + tol.confusion())
5496 .unwrap_or(placed.len() - 2);
5497 let (a, b) = (&placed[k], &placed[k + 1]);
5498 let f = ((along - a.0) / (b.0 - a.0)).clamp(0.0, 1.0);
5499 let out = Transform::from_frame(frame);
5500 let target = tolerance * 0.1;
5504 let fitted = ogeom_geom::fit::fit_curve_sampled(
5505 |g| {
5506 let (p, q) = (a.1.at(g), b.1.at(g));
5507 Ok(out.apply(p + (q - p) * f))
5508 },
5509 &fractions(AROUND),
5510 true,
5511 3,
5512 target,
5513 tol,
5514 )?;
5515 if !fitted.met {
5516 ogeom_bail!(
5517 NotDone,
5518 "a blended section reached {} against a target of {target}",
5519 fitted.error
5520 );
5521 }
5522 let curve: ogeom_geom::Curve = fitted.curve.into();
5523 let range = curve.domain();
5524 let edge = ogeom_algo::make_edge(model, curve, range, tol)?.shape;
5525 rings.push(ogeom_algo::make_wire(model, &[edge], tol)?.shape);
5526 Ok(())
5527 };
5528 let frame_at = |i: usize| station_frame(&stations[i], normals[i], tol);
5529 let at_along = |along: f64| -> OgeomResult<Frame> {
5532 let i = run
5533 .windows(2)
5534 .position(|w| along <= w[1] + tol.confusion())
5535 .unwrap_or(run.len() - 2);
5536 let f = ((along - run[i]) / (run[i + 1] - run[i]).max(f64::MIN_POSITIVE)).clamp(0.0, 1.0);
5537 let at = stations[i].at + (stations[i + 1].at - stations[i].at) * f;
5538 let tangent = stations[i].tangent * (1.0 - f) + stations[i + 1].tangent * f;
5539 let normal = normals[i] * (1.0 - f) + normals[i + 1] * f;
5540 let normal = normal - tangent * normal.dot(tangent) / tangent.dot(tangent);
5541 Frame::new(
5542 at,
5543 Direction::new(tangent, tol)?,
5544 Direction::new(normal, tol)?,
5545 tol,
5546 )
5547 };
5548 if apex.is_none_or(|(_, at_end)| at_end) {
5551 ring_at(model, first, &at_along(first)?)?;
5552 }
5553 for (i, &along) in run.iter().enumerate() {
5554 if along > first + tol.confusion() && along < last - tol.confusion() {
5555 ring_at(model, along, &frame_at(i)?)?;
5556 }
5557 }
5558 if apex.is_none_or(|(_, at_end)| !at_end) {
5559 ring_at(model, last, &at_along(last)?)?;
5560 }
5561 if let Some((point, at_end)) = apex {
5562 if !at_end {
5563 rings.reverse();
5564 }
5565 rings.push(model.add_vertex(VertexData::new(point)));
5566 }
5567 let mut built = make_loft_skinned(model, &rings, tolerance, tol)?;
5568 built.history.generate(spine, built.shape.clone());
5569 for section in sections {
5570 built.history.generate(section, built.shape.clone());
5571 }
5572 Ok(built)
5573}
5574
5575fn evenly(
5578 model: &Model,
5579 spine: &Shape,
5580 stations: Vec<SpineStation>,
5581 count: u32,
5582 keep: bool,
5583 tol: Tolerances,
5584) -> OgeomResult<Vec<SpineStation>> {
5585 evenly_by(model, spine, stations, &|_| count, keep, tol)
5586}
5587
5588fn evenly_by(
5590 model: &Model,
5591 spine: &Shape,
5592 stations: Vec<SpineStation>,
5593 count: &dyn Fn(usize) -> u32,
5594 keep: bool,
5595 tol: Tolerances,
5596) -> OgeomResult<Vec<SpineStation>> {
5597 let edges: Vec<Shape> = match model.kind_of(spine)? {
5598 ShapeType::Edge => vec![spine.clone()],
5599 ShapeType::Wire => model.ordered_children_of(spine)?,
5600 _ => return Ok(stations),
5601 };
5602 let mut out: Vec<SpineStation> = Vec::with_capacity(stations.len() + edges.len() * 32);
5603 for (e, edge) in edges.iter().enumerate() {
5604 let on: Vec<SpineStation> = stations.iter().copied().filter(|s| s.edge == e).collect();
5605 let (Some(first), Some(last)) = (on.first().copied(), on.last().copied()) else {
5606 continue;
5607 };
5608 let (curve, _) = spine_curve_of(model, edge)?;
5609 let sense = if curve.d1_at(first.t, tol)?.dot(first.tangent) >= 0.0 {
5610 1.0
5611 } else {
5612 -1.0
5613 };
5614 let mut ts: Vec<f64> = if keep {
5615 on.iter().map(|s| s.t).collect()
5616 } else {
5617 vec![first.t, last.t]
5618 };
5619 let count = count(e);
5620 for k in 1..count {
5621 ts.push(first.t + (last.t - first.t) * f64::from(k) / f64::from(count));
5622 }
5623 ts.sort_by(|a, b| {
5624 if first.t <= last.t {
5625 a.total_cmp(b)
5626 } else {
5627 b.total_cmp(a)
5628 }
5629 });
5630 ts.dedup_by(|a, b| (*a - *b).abs() <= tol.parametric());
5631 for t in ts {
5632 let d = curve.d1_at(t, tol)?;
5633 out.push(SpineStation {
5634 at: curve.point_at(t, tol)?,
5635 tangent: d * sense / d.magnitude(),
5636 edge: e,
5637 t,
5638 });
5639 }
5640 }
5641 Ok(out)
5642}
5643
5644pub(crate) fn law_normals(
5650 model: &Model,
5651 stations: &[SpineStation],
5652 law: &PipeLaw<'_>,
5653 reach: f64,
5654 tol: Tolerances,
5655) -> OgeomResult<Vec<Vector>> {
5656 law_normals_on(&guide_curves(model, law)?, stations, law, reach, tol)
5657}
5658
5659fn guide_curves(
5662 model: &Model,
5663 law: &PipeLaw<'_>,
5664) -> OgeomResult<Vec<(ogeom_geom::Curve, (f64, f64))>> {
5665 let PipeLaw::Auxiliary { guide } = law else {
5666 return Ok(Vec::new());
5667 };
5668 let edges: Vec<Shape> = match model.kind_of(guide)? {
5669 ShapeType::Edge => vec![(*guide).clone()],
5670 ShapeType::Wire => model.ordered_children_of(guide)?,
5671 _ => ogeom_bail!(Construction, "an auxiliary spine is an edge or a wire"),
5672 };
5673 let mut curves = Vec::with_capacity(edges.len());
5674 for edge in &edges {
5675 curves.push(spine_curve_of(model, edge)?);
5676 }
5677 Ok(curves)
5678}
5679
5680fn law_normals_on(
5683 curves: &[(ogeom_geom::Curve, (f64, f64))],
5684 stations: &[SpineStation],
5685 law: &PipeLaw<'_>,
5686 reach: f64,
5687 tol: Tolerances,
5688) -> OgeomResult<Vec<Vector>> {
5689 match law {
5690 PipeLaw::Fixed => ogeom_bail!(
5693 Construction,
5694 "a fixed section keeps its own frame; it has no frame normals"
5695 ),
5696 PipeLaw::RotationMinimizing => Ok(rmf_normals(stations)),
5697 PipeLaw::Frenet => frenet_normals(stations, tol),
5698 PipeLaw::Binormal(b) => stations
5699 .iter()
5700 .map(|s| {
5701 let t = s.tangent;
5702 let b = b.vector() - t * b.vector().dot(t);
5703 if b.magnitude() <= tol.angular() {
5704 ogeom_bail!(
5705 Construction,
5706 "the spine runs along the binormal at {:?}; no frame keeps it",
5707 s.at
5708 );
5709 }
5710 let n = (b / b.magnitude()).cross(t);
5711 Ok(n / n.magnitude())
5712 })
5713 .collect(),
5714 PipeLaw::Auxiliary { .. } => {
5715 let mut out = Vec::with_capacity(stations.len());
5716 let mut last: Option<Point> = None;
5717 for s in stations {
5718 let (p, t) = (s.at, s.tangent);
5719 let mut best: Option<Point> = None;
5723 for (curve, range) in curves {
5724 const STEPS: u32 = 256;
5725 let height = |u: f64| -> OgeomResult<(f64, Point)> {
5726 let q = curve.point_at(u, tol)?;
5727 Ok(((q - p).dot(t), q))
5728 };
5729 let at =
5730 |k: u32| range.0 + (range.1 - range.0) * f64::from(k) / f64::from(STEPS);
5731 let mut prev = height(at(0))?;
5732 for k in 1..=STEPS {
5733 let here = height(at(k))?;
5734 if prev.0 == 0.0 || prev.0.signum() != here.0.signum() {
5735 let q = if prev.0 == 0.0 {
5737 prev.1
5738 } else {
5739 let (mut lo, mut hi) = (at(k - 1), at(k));
5740 let mut f_lo = prev.0;
5741 for _ in 0..60 {
5742 let mid = f64::midpoint(lo, hi);
5743 let (f_mid, _) = height(mid)?;
5744 if f_mid.signum() == f_lo.signum() {
5745 lo = mid;
5746 f_lo = f_mid;
5747 } else {
5748 hi = mid;
5749 }
5750 }
5751 height(f64::midpoint(lo, hi))?.1
5752 };
5753 let near = last.unwrap_or(p);
5754 if best.is_none_or(|b| q.distance(near) < b.distance(near)) {
5755 best = Some(q);
5756 }
5757 }
5758 prev = here;
5759 }
5760 }
5761 if best.is_none() {
5762 let near = last.unwrap_or(p);
5763 for (curve, range) in curves {
5764 for u in [range.0, range.1] {
5765 let q = curve.point_at(u, tol)?;
5766 let h = (q - p).dot(t);
5767 if h.abs() > reach {
5768 continue;
5769 }
5770 let d = curve.d1_at(u, tol)?;
5771 let along = d.dot(t);
5772 let onto = if along.abs() > tol.angular() * d.magnitude() {
5773 q - d * (h / along)
5774 } else {
5775 q
5776 };
5777 let q = if onto.distance(q) <= 2.0 * reach {
5780 onto
5781 } else {
5782 q
5783 };
5784 if best.is_none_or(|b| q.distance(near) < b.distance(near)) {
5785 best = Some(q);
5786 }
5787 }
5788 }
5789 }
5790 let Some(q) = best else {
5791 ogeom_bail!(
5792 Construction,
5793 "the auxiliary spine does not cross the plane square to the \
5794 spine at {p:?}"
5795 );
5796 };
5797 last = Some(q);
5798 let toward = (q - p) - t * (q - p).dot(t);
5799 if toward.magnitude() <= tol.confusion() {
5800 ogeom_bail!(
5801 Construction,
5802 "the auxiliary spine meets the spine at {p:?}; no direction \
5803 points at it"
5804 );
5805 }
5806 out.push(toward / toward.magnitude());
5807 }
5808 Ok(out)
5809 }
5810 }
5811}
5812
5813#[allow(clippy::too_many_lines)]
5815fn pipe_shell_law(
5816 model: &mut Model,
5817 profile: &Shape,
5818 spine: &Shape,
5819 law: &PipeLaw<'_>,
5820 tolerance: f64,
5821 tol: Tolerances,
5822) -> OgeomResult<Built> {
5823 const AROUND: usize = 40;
5824 let frenet = matches!(law, PipeLaw::Frenet);
5825 let classic = matches!(law, PipeLaw::RotationMinimizing | PipeLaw::Frenet);
5826
5827 if classic && let Some(exact) = exact_legs(model, profile, spine, frenet, tol)? {
5828 return Ok(exact);
5829 }
5830 let stations = shell_stations(model, spine, tol)?;
5831 let stations = if classic {
5832 stations
5833 } else {
5834 let wanted = densified(model, spine, stations.clone(), law, tolerance, tol)?.len();
5837 let mut lengths: Vec<f64> = Vec::new();
5840 for pair in stations.windows(2) {
5841 let e = pair[1].edge;
5842 if lengths.len() <= e {
5843 lengths.resize(e + 1, 0.0);
5844 }
5845 if pair[0].edge == e {
5846 lengths[e] += pair[0].at.distance(pair[1].at);
5847 }
5848 }
5849 let total: f64 = lengths.iter().sum();
5850 #[allow(
5851 clippy::cast_precision_loss,
5852 clippy::cast_possible_truncation,
5853 clippy::cast_sign_loss
5854 )]
5855 let share = |e: usize| -> u32 {
5856 let length = lengths.get(e).copied().unwrap_or(0.0);
5857 if total <= 0.0 {
5858 return 2;
5859 }
5860 ((wanted as f64 * length / total).ceil() as u32).max(2)
5861 };
5862 evenly_by(model, spine, stations, &share, false, tol)?
5863 };
5864 let corners: Vec<usize> = (0..stations.len() - 1)
5866 .filter(|&i| {
5867 stations[i].at.distance(stations[i + 1].at) <= tol.confusion()
5868 && (stations[i]
5869 .tangent
5870 .cross(stations[i + 1].tangent)
5871 .magnitude()
5872 > tol.angular()
5873 || stations[i].tangent.dot(stations[i + 1].tangent) < 0.0)
5874 })
5875 .collect();
5876 let kinks: Vec<usize> = (0..stations.len() - 1)
5880 .filter(|&i| stations[i].at.distance(stations[i + 1].at) <= tol.confusion())
5881 .collect();
5882 let ring = stations[0].at.distance(stations[stations.len() - 1].at) <= tol.confusion() * 10.0;
5883 if !classic && (ring || !corners.is_empty()) {
5884 ogeom_bail!(
5885 Construction,
5886 "an auxiliary or binormal law sweeps an open spine with no sharp corner"
5887 );
5888 }
5889 if !classic {
5890 return law_loft(model, profile, spine, &stations, law, tolerance, tol);
5891 }
5892 if ring && kinks.is_empty() {
5893 return closed_pipe_shell(model, profile, spine, stations, frenet, tolerance, tol);
5894 }
5895 if frenet && !corners.is_empty() {
5903 ogeom_bail!(
5904 Construction,
5905 "a Frenet frame has no direction at a corner; sweep a cornered \
5906 spine with the rotation-minimizing frame"
5907 );
5908 }
5909 let normals = law_normals(model, &stations, law, tolerance, tol)?;
5910 let normals = if ring {
5915 let mut extended = stations.clone();
5916 extended.push(stations[0]);
5917 let carried = rmf_normals(&extended);
5918 let (n0, n_home) = (carried[0], carried[carried.len() - 1]);
5919 let t0 = stations[0].tangent;
5920 let twist = (n0.cross(n_home).dot(t0)).atan2(n0.dot(n_home));
5921 let mut lengths = vec![0.0_f64];
5922 for pair in extended.windows(2) {
5923 let held = lengths[lengths.len() - 1];
5924 lengths.push(held + pair[0].at.distance(pair[1].at));
5925 }
5926 let total = lengths[lengths.len() - 1];
5927 carried
5928 .iter()
5929 .take(stations.len())
5930 .enumerate()
5931 .map(|(i, n)| {
5932 let phi = -twist * lengths[i] / total;
5933 let t = extended[i].tangent;
5934 let v = *n * phi.cos() + t.cross(*n) * phi.sin();
5935 let v = v - t * v.dot(t);
5936 v / v.magnitude()
5937 })
5938 .collect()
5939 } else {
5940 normals
5941 };
5942 let mitre: Vec<Option<(Point, Vector)>> = {
5946 let mut out: Vec<Option<(Point, Vector)>> = vec![None; stations.len()];
5947 for &k in &kinks {
5948 let n = stations[k].tangent + stations[k + 1].tangent;
5949 if n.magnitude() <= tol.angular() {
5950 ogeom_bail!(
5951 Construction,
5952 "the spine doubles straight back on itself; no mitre \
5953 plane divides that corner"
5954 );
5955 }
5956 out[k] = Some((stations[k].at, n));
5957 out[k + 1] = Some((stations[k + 1].at, n));
5958 }
5959 if ring {
5960 let wrap = stations.len() - 1;
5961 let n = stations[wrap].tangent + stations[0].tangent;
5962 if n.magnitude() <= tol.angular() {
5963 ogeom_bail!(
5964 Construction,
5965 "the spine doubles straight back on itself; no mitre \
5966 plane divides that corner"
5967 );
5968 }
5969 out[wrap] = Some((stations[wrap].at, n));
5970 out[0] = Some((stations[0].at, n));
5971 }
5972 out
5973 };
5974 let runs: Vec<(usize, usize)> = {
5975 let mut out = Vec::with_capacity(kinks.len() + 1);
5976 let mut start = 0;
5977 for &k in &kinks {
5978 out.push((start, k));
5979 start = k + 1;
5980 }
5981 out.push((start, stations.len() - 1));
5982 out
5983 };
5984 let straight = |rs: usize, re: usize| -> bool {
5990 if !classic {
5991 return false;
5992 }
5993 let t0 = stations[rs].tangent;
5994 (rs..=re).all(|i| stations[i].tangent.cross(t0).magnitude() <= tol.angular())
5995 };
5996 struct CornerPair {
6004 before: (usize, usize),
6005 after: (usize, usize),
6006 curved: bool,
6007 }
6008 let corner_pairs: Vec<CornerPair> = {
6009 let mut out = Vec::new();
6010 for pair in runs.windows(2) {
6013 out.push(CornerPair {
6014 before: pair[0],
6015 after: pair[1],
6016 curved: corners.contains(&pair[0].1)
6017 && (!straight(pair[0].0, pair[0].1) || !straight(pair[1].0, pair[1].1)),
6018 });
6019 }
6020 if ring && runs.len() > 1 {
6021 let (before, after) = (runs[runs.len() - 1], runs[0]);
6022 let turns = stations[before.1]
6023 .tangent
6024 .cross(stations[after.0].tangent)
6025 .magnitude()
6026 > tol.angular()
6027 || stations[before.1].tangent.dot(stations[after.0].tangent) < 0.0;
6028 out.push(CornerPair {
6029 before,
6030 after,
6031 curved: turns && (!straight(before.0, before.1) || !straight(after.0, after.1)),
6032 });
6033 }
6034 out
6035 };
6036 let walk = SpineWalk {
6037 curves: walk_curves(model, spine)?,
6038 stations: &stations,
6039 normals: &normals,
6040 };
6041 let join_reach = tolerance.max(tol.confusion() * 100.0);
6042 let curved_join = |pair: &CornerPair, ab: (f64, f64)| -> OgeomResult<CornerJoin> {
6043 let join = walk.join(pair.before, pair.after, ab, tol)?;
6044 if join.gap > join_reach {
6045 ogeom_bail!(
6046 Construction,
6047 "a skew corner against a curved leg is still owed its frame \
6048 law: the legs' generators miss by {}; see docs/PARITY.md, \
6049 offset.sweeps",
6050 join.gap
6051 );
6052 }
6053 Ok(join)
6054 };
6055 let shares_start = |ri: usize| -> bool {
6060 ri > 0
6061 && corner_pairs
6062 .iter()
6063 .any(|pair| pair.curved && pair.after == runs[ri])
6064 };
6065 let shares_end = |ri: usize| -> bool {
6066 ring && ri + 1 == runs.len()
6067 && corner_pairs
6068 .iter()
6069 .any(|pair| pair.curved && pair.before == runs[ri] && pair.after == runs[0])
6070 };
6071 let curved_at = |i: usize| -> Option<&CornerPair> {
6073 corner_pairs
6074 .iter()
6075 .find(|pair| pair.curved && (pair.before.1 == i || pair.after.0 == i))
6076 };
6077
6078 let loops: Vec<Shape> = match model.kind_of(profile)? {
6081 ShapeType::Face => explore(model, profile, Filter::OfType(ShapeType::Wire))?,
6082 ShapeType::Wire => vec![profile.clone()],
6083 other => ogeom_bail!(
6084 Construction,
6085 "a pipe shell sweeps a planar wire or face, not a {other:?}"
6086 ),
6087 };
6088 if loops.is_empty() {
6089 ogeom_bail!(Construction, "the profile has no loop to sweep");
6090 }
6091 let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
6092 ogeom_bail!(Construction, "a pipe shell sweeps a planar profile");
6093 };
6094 let t0 = stations[0].tangent;
6095 let exact_t0 = {
6098 let first = match model.kind_of(spine)? {
6099 ShapeType::Edge => spine.clone(),
6100 _ => model.ordered_children_of(spine)?[0].clone(),
6101 };
6102 let (curve, range) = spine_curve_of(model, &first)?;
6103 let reversed = first.orientation() == ogeom_topo::Orientation::Reversed;
6104 let d = curve.d1_at(if reversed { range.1 } else { range.0 }, tol)?;
6105 let d = if reversed { -d } else { d };
6106 d / d.magnitude()
6107 };
6108 if plane.normal().vector().cross(exact_t0).magnitude() > tol.angular() {
6109 ogeom_bail!(
6110 Construction,
6111 "the profile leans along its spine; a pipe shell runs square to \
6112 the start"
6113 );
6114 }
6115 if plane.distance_to(stations[0].at) > tol.confusion() * 100.0 {
6116 ogeom_bail!(
6117 Construction,
6118 "the profile does not sit at the spine's start"
6119 );
6120 }
6121
6122 let x0 = normals[0];
6128 let y0 = t0.cross(x0);
6129 let origin = stations[0].at;
6130 let flat = |p: Point| -> (f64, f64) { ((p - origin).dot(x0), (p - origin).dot(y0)) };
6131 let skew: Vec<(usize, usize)> = {
6136 let mut probes: Vec<(f64, f64)> = Vec::new();
6137 for wire in &loops {
6138 probes.extend(sample_wire(model, wire, AROUND, tol)?.into_iter().map(flat));
6139 }
6140 let mut out = Vec::new();
6141 for pair in corner_pairs.iter().filter(|pair| pair.curved) {
6142 let mut worst = 0.0_f64;
6143 for ab in &probes {
6144 worst = worst.max(walk.join(pair.before, pair.after, *ab, tol)?.gap);
6145 }
6146 if worst > join_reach {
6147 out.push((pair.before.1, pair.after.0));
6148 }
6149 }
6150 out
6151 };
6152 if !skew.is_empty() {
6153 let probes: Vec<(f64, f64)> = {
6154 let mut out = Vec::new();
6155 for wire in &loops {
6156 out.extend(sample_wire(model, wire, AROUND, tol)?.into_iter().map(flat));
6157 }
6158 out
6159 };
6160 return mitred_pieces(
6161 model, profile, spine, &stations, &skew, ring, &probes, tolerance, tol,
6162 );
6163 }
6164 let place = |i: usize, (a, b): (f64, f64)| -> OgeomResult<Point> {
6165 if let Some(pair) = curved_at(i) {
6166 return Ok(curved_join(pair, (a, b))?.at);
6167 }
6168 let x = normals[i];
6169 let y = stations[i].tangent.cross(x);
6170 let p = stations[i].at + x * a + y * b;
6171 Ok(match mitre[i] {
6172 Some((corner, n)) => {
6173 let t = stations[i].tangent;
6174 p + t * ((corner - p).dot(n) / t.dot(n))
6175 }
6176 None => p,
6177 })
6178 };
6179 struct RerowColumn {
6182 along: Vec<(f64, f64)>,
6183 length: f64,
6184 }
6185 let crossing =
6188 |run: (usize, usize), ab: (f64, f64), at_start: bool| -> OgeomResult<Option<CornerJoin>> {
6189 let pair = corner_pairs.iter().find(|pair| {
6190 pair.curved
6191 && if at_start {
6192 pair.after == run
6193 } else {
6194 pair.before == run
6195 }
6196 });
6197 pair.map(|pair| curved_join(pair, ab)).transpose()
6198 };
6199 let column_ends = |(rs, re): (usize, usize),
6204 ab: (f64, f64)|
6205 -> OgeomResult<(Option<CornerJoin>, Option<CornerJoin>)> {
6206 let run = (rs, re);
6207 let (start, end) = (crossing(run, ab, true)?, crossing(run, ab, false)?);
6208 #[allow(clippy::cast_precision_loss)]
6209 let (rsf, ref_) = (rs as f64, re as f64);
6210 if start.as_ref().is_some_and(|j| j.s2 >= ref_ - 0.5)
6211 || end.as_ref().is_some_and(|j| j.s1 <= rsf + 0.5)
6212 {
6213 ogeom_bail!(
6214 Construction,
6215 "a leg is shorter than its corner's reach; the mitre would \
6216 run off its far end"
6217 );
6218 }
6219 Ok((start, end))
6220 };
6221 let straight_column = |(rs, re): (usize, usize), ab: (f64, f64)| -> OgeomResult<Vec<Point>> {
6225 let (start, end) = column_ends((rs, re), ab)?;
6226 Ok(vec![
6227 match start {
6228 Some(j) => j.at,
6229 None => place(rs, ab)?,
6230 },
6231 match end {
6232 Some(j) => j.at,
6233 None => place(re, ab)?,
6234 },
6235 ])
6236 };
6237 let rerow_column = |(rs, re): (usize, usize), ab: (f64, f64)| -> OgeomResult<RerowColumn> {
6245 let run = (rs, re);
6246 let (start, end) = column_ends(run, ab)?;
6247 #[allow(clippy::cast_precision_loss)]
6248 let lo = start.map_or(rs as f64, |j| j.s2);
6249 #[allow(clippy::cast_precision_loss)]
6250 let hi = end.map_or(re as f64, |j| j.s1);
6251 let mut nodes: Vec<f64> = vec![lo];
6258 #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
6259 {
6260 let mut m = (lo * 8.0).floor() as i64 + 1;
6261 while (m as f64) / 8.0 < hi {
6262 let sp = (m as f64) / 8.0;
6263 if sp - lo > 1e-9 && hi - sp > 1e-9 {
6264 nodes.push(sp);
6265 }
6266 m += 1;
6267 }
6268 }
6269 nodes.push(hi);
6270 let mut along: Vec<(f64, f64)> = Vec::with_capacity(nodes.len());
6271 let mut prev: Option<Point> = None;
6272 let mut length = 0.0;
6273 for sp in nodes {
6274 let p = walk.generator(sp, run, ab, tol)?;
6275 if let Some(q) = prev {
6276 length += q.distance(p);
6277 }
6278 along.push((length, sp));
6279 prev = Some(p);
6280 }
6281 Ok(RerowColumn { along, length })
6282 };
6283 let rerowed_at =
6285 |run: (usize, usize), column: &RerowColumn, ab: (f64, f64), f: f64| -> OgeomResult<Point> {
6286 let along = &column.along;
6287 let target = column.length * f;
6288 let at = along
6289 .partition_point(|(l, _)| *l < target)
6290 .clamp(1, along.len() - 1);
6291 let ((l0, s0), (l1, s1)) = (along[at - 1], along[at]);
6292 let g = if l1 > l0 {
6293 ((target - l0) / (l1 - l0)).clamp(0.0, 1.0)
6294 } else {
6295 0.0
6296 };
6297 walk.generator(s0 + (s1 - s0) * g, run, ab, tol)
6298 };
6299 #[allow(clippy::type_complexity)]
6303 let rerowed_point = |(rs, re): (usize, usize),
6304 held: &std::cell::RefCell<ogeom_core::FastMap<u64, RerowColumn>>,
6305 u: f64,
6306 ab: (f64, f64),
6307 s: f64|
6308 -> OgeomResult<Point> {
6309 if !held.borrow().contains_key(&u.to_bits()) {
6310 let column = rerow_column((rs, re), ab)?;
6311 held.borrow_mut().insert(u.to_bits(), column);
6312 }
6313 let held = held.borrow();
6314 #[allow(clippy::cast_precision_loss)]
6315 let f = (s - rs as f64) / (re - rs) as f64;
6316 rerowed_at((rs, re), &held[&u.to_bits()], ab, f)
6317 };
6318 let plain = |run: (usize, usize)| -> bool {
6324 !straight(run.0, run.1)
6325 && !corner_pairs
6326 .iter()
6327 .any(|pair| pair.curved && (pair.after == run || pair.before == run))
6328 };
6329 let along_run = |run: (usize, usize), s: f64, ab: (f64, f64)| -> OgeomResult<Point> {
6330 if s.fract() == 0.0 {
6331 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
6332 let i = s as usize;
6333 place(i, ab)
6334 } else {
6335 walk.generator(s, run, ab, tol)
6336 }
6337 };
6338 let last = stations.len() - 1;
6339
6340 enum LoopWall {
6341 Ring {
6342 ring0: Shape,
6343 ring1: Shape,
6344 },
6345 Chain {
6346 bottoms: Vec<Shape>,
6347 tops: Vec<Shape>,
6348 },
6349 }
6350 let mut faces: Vec<Shape> = Vec::new();
6351 let mut ends: Vec<LoopWall> = Vec::with_capacity(loops.len());
6352 for (li, wire) in loops.iter().enumerate() {
6353 let hole = li != 0;
6354 let edges = model.ordered_children_of(wire)?;
6355 let single_smooth = edges.len() == 1 && {
6356 let (curve, _) = spine_curve_of(model, &edges[0])?;
6357 !matches!(curve, ogeom_geom::Curve::Line(_))
6358 && ogeom_algo::edge_vertices(model, &edges[0])?.is_some_and(|(a, b)| a.is_same(&b))
6359 };
6360 if single_smooth {
6361 let profile_loop = section_loop(model, wire, None, tol)?;
6362 let mut ring0: Option<Shape> = None;
6366 let mut ring1: Option<Shape> = None;
6367 for (ri, &(rs, re)) in runs.iter().enumerate() {
6368 let held = std::cell::RefCell::default();
6371 let skin = if plain((rs, re)) {
6372 Skin::swept(
6373 |f, s| along_run((rs, re), s, flat(profile_loop.at(f))),
6374 fractions(AROUND),
6375 (rs, re),
6376 true,
6377 )?
6378 } else if straight(rs, re) {
6379 Skin::columns(
6380 |f| straight_column((rs, re), flat(profile_loop.at(f))),
6381 fractions(AROUND),
6382 true,
6383 false,
6384 false,
6385 )?
6386 } else {
6387 Skin::swept(
6388 |f, s| rerowed_point((rs, re), &held, f, flat(profile_loop.at(f)), s),
6389 fractions(AROUND),
6390 (rs, re),
6391 true,
6392 )?
6393 };
6394 let shared_start = shares_start(ri).then_some(()).and(ring1.as_ref());
6395 let shared_end = shares_end(ri).then_some(()).and(ring0.as_ref());
6396 let wall = skinned_wall(model, &skin, (shared_start, shared_end), tolerance, tol)?;
6397 faces.push(if hole {
6398 wall.face.reversed()
6399 } else {
6400 wall.face.clone()
6401 });
6402 if ring0.is_none() {
6403 ring0 = Some(wall.ring0);
6404 }
6405 ring1 = Some(wall.ring1);
6406 }
6407 let (Some(ring0), Some(ring1)) = (ring0, ring1) else {
6408 ogeom_bail!(Construction, "the sweep produced no wall");
6409 };
6410 ends.push(LoopWall::Ring { ring0, ring1 });
6411 } else {
6412 let count = edges.len();
6414 let mut corner_flat: Vec<(f64, f64)> = Vec::with_capacity(count);
6415 for edge in &edges {
6416 let Some((start, _)) = ogeom_algo::edge_vertices(model, edge)? else {
6419 ogeom_bail!(Construction, "a profile edge has no vertices");
6420 };
6421 let Some(data) = model.node(&start).and_then(|n| n.data().as_vertex()) else {
6422 ogeom_bail!(Construction, "a profile vertex holds no data");
6423 };
6424 corner_flat.push(flat(data.point));
6425 }
6426 let make_corners = |model: &mut Model, station: usize| -> OgeomResult<Vec<Shape>> {
6427 let mut out = Vec::with_capacity(corner_flat.len());
6428 for ab in &corner_flat {
6429 out.push(ogeom_algo::make_vertex(model, place(station, *ab)?).shape);
6430 }
6431 Ok(out)
6432 };
6433 let mut corners_at: Vec<Option<Vec<Shape>>> = vec![None; stations.len()];
6437 if ring {
6438 let mut worst = 0.0_f64;
6446 for ab in &corner_flat {
6447 worst = worst.max(place(last, *ab)?.distance(place(0, *ab)?));
6448 for &k in &kinks {
6449 worst = worst.max(place(k, *ab)?.distance(place(k + 1, *ab)?));
6450 }
6451 }
6452 if worst > tolerance.max(tol.confusion() * 100.0) {
6453 ogeom_bail!(
6454 Construction,
6455 "a skew-cornered ring's sections do not meet on \
6456 their mitres; the out-of-plane corner's frame law \
6457 is still owed; see docs/PARITY.md, offset.sweeps"
6458 );
6459 }
6460 let set = make_corners(model, 0)?;
6461 if worst > tol.confusion() {
6462 for v in &set {
6463 model.widen(v, ogeom_core::Tolerance::new(worst * 2.0)?)?;
6464 }
6465 }
6466 corners_at[0] = Some(set.clone());
6467 corners_at[last] = Some(set);
6468 } else {
6469 corners_at[0] = Some(make_corners(model, 0)?);
6470 corners_at[last] = Some(make_corners(model, last)?);
6471 }
6472 for &k in &kinks {
6473 let set = make_corners(model, k)?;
6474 corners_at[k] = Some(set.clone());
6475 corners_at[k + 1] = Some(set);
6476 }
6477
6478 let hint_flat = {
6480 let mut a = 0.0;
6481 let mut b = 0.0;
6482 for (fa, fb) in &corner_flat {
6483 a += fa;
6484 b += fb;
6485 }
6486 #[allow(clippy::cast_precision_loss)]
6487 let n = count as f64;
6488 (a / n, b / n)
6489 };
6490
6491 let mut bottoms = Vec::with_capacity(count);
6492 let mut tops = Vec::with_capacity(count);
6493 let mut run_rails: Vec<(Option<Shape>, Option<Shape>)> = vec![(None, None); runs.len()];
6497 for (ei, edge) in edges.iter().enumerate() {
6498 let (curve, range) = spine_curve_of(model, edge)?;
6499 let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
6500 const ALONG_EDGE: usize = 8;
6501 let edge_flat = |f: f64| -> OgeomResult<(f64, f64)> {
6502 let t = if reversed {
6503 range.1 - (range.1 - range.0) * f
6504 } else {
6505 range.0 + (range.1 - range.0) * f
6506 };
6507 Ok(flat(curve.point_at(t, tol)?))
6508 };
6509 let next = (ei + 1) % count;
6510 let mut bottom: Option<Shape> = None;
6511 let mut top: Option<Shape> = None;
6512 for (ri, &(rs, re)) in runs.iter().enumerate() {
6513 let held = std::cell::RefCell::default();
6516 let skin = if plain((rs, re)) {
6517 Skin::swept(
6518 |f, s| along_run((rs, re), s, edge_flat(f)?),
6519 fractions(ALONG_EDGE),
6520 (rs, re),
6521 false,
6522 )?
6523 } else if straight(rs, re) {
6524 Skin::columns(
6525 |f| straight_column((rs, re), edge_flat(f)?),
6526 fractions(ALONG_EDGE),
6527 false,
6528 false,
6529 false,
6530 )?
6531 } else {
6532 Skin::swept(
6533 |f, s| rerowed_point((rs, re), &held, f, edge_flat(f)?, s),
6534 fractions(ALONG_EDGE),
6535 (rs, re),
6536 false,
6537 )?
6538 };
6539 let shared_start = shares_start(ri).then_some(()).and(top.as_ref());
6540 let shared_end = shares_end(ri).then_some(()).and(bottom.as_ref());
6541 let mid_i = usize::midpoint(rs, re);
6542 let hint = {
6543 let x = normals[mid_i];
6544 let y = stations[mid_i].tangent.cross(x);
6545 stations[mid_i].at + x * hint_flat.0 + y * hint_flat.1
6546 };
6547 let (Some(from), Some(to)) = (&corners_at[rs], &corners_at[re]) else {
6548 ogeom_bail!(Construction, "a run boundary has no corners");
6549 };
6550 let (first_rail0, prev_rail1) = run_rails[ri].clone();
6551 let shared_rail0 = if ei > 0 { prev_rail1 } else { None };
6552 let shared_rail1 = if ei + 1 == count && count > 1 {
6553 first_rail0.clone()
6554 } else {
6555 None
6556 };
6557 let strip = skinned_strip(
6558 model,
6559 &skin,
6560 (&from[ei], &from[next], &to[ei], &to[next]),
6561 [
6562 shared_start,
6563 shared_end,
6564 shared_rail0.as_ref(),
6565 shared_rail1.as_ref(),
6566 ],
6567 hint,
6568 hole,
6569 tolerance,
6570 tol,
6571 )?;
6572 run_rails[ri] = (
6573 if ei == 0 {
6574 Some(strip.rail0.clone())
6575 } else {
6576 first_rail0
6577 },
6578 Some(strip.rail1.clone()),
6579 );
6580 faces.push(strip.face.clone());
6581 if bottom.is_none() {
6582 bottom = Some(strip.bottom);
6583 }
6584 top = Some(strip.top);
6585 }
6586 let (Some(bottom), Some(top)) = (bottom, top) else {
6587 ogeom_bail!(Construction, "the sweep produced no strip");
6588 };
6589 bottoms.push(bottom);
6590 tops.push(top);
6591 }
6592 ends.push(LoopWall::Chain { bottoms, tops });
6593 }
6594 }
6595
6596 for end in 0..if ring { 0 } else { 2 } {
6601 let (at, outward) = if end == 0 {
6602 (stations[0].at, -stations[0].tangent)
6603 } else {
6604 (stations[last].at, stations[last].tangent)
6605 };
6606 let cap_plane = Plane::through(at, Direction::new(outward, tol)?);
6607 let mut loop_edges: Vec<Vec<Shape>> = Vec::with_capacity(ends.len());
6608 for wall in &ends {
6609 loop_edges.push(match wall {
6610 LoopWall::Ring { ring0, ring1 } => {
6611 vec![if end == 0 {
6612 ring0.clone()
6613 } else {
6614 ring1.clone()
6615 }]
6616 }
6617 LoopWall::Chain { bottoms, tops } => {
6618 if end == 0 {
6619 bottoms.clone()
6620 } else {
6621 tops.clone()
6622 }
6623 }
6624 });
6625 }
6626 let mut reach = 1.0_f64;
6627 for edges in &loop_edges {
6628 for edge in edges {
6629 let (curve, range) = spine_curve_of(model, edge)?;
6630 for t in 0..8 {
6631 let p =
6632 curve.point_at(range.0 + (range.1 - range.0) * f64::from(t) / 8.0, tol)?;
6633 reach = reach.max(p.distance(at) * 2.0);
6634 }
6635 }
6636 }
6637 let cap_surface: SurfaceGeometry =
6638 PlaneSurface::over(cap_plane, (-reach, reach), (-reach, reach))?.into();
6639 let mut wires: Vec<Shape> = Vec::with_capacity(loop_edges.len());
6642 for (li, edges) in loop_edges.iter().enumerate() {
6643 let ring = walked_about(model, edges, outward, li == 0, tol)?;
6644 wires.push(ogeom_algo::make_wire(model, &ring, tol)?.shape);
6645 }
6646 let face = ogeom_algo::make_face(model, cap_surface.clone(), &wires, tol)?.shape;
6647 let cap_id = {
6648 let Some(node) = model.node(&face) else {
6649 ogeom_bail!(Dangling, "the cap just built is not in this model");
6650 };
6651 let ogeom_topo::NodeData::Face(data) = node.data() else {
6652 ogeom_bail!(Construction, "the cap holds no face data");
6653 };
6654 data.surface
6655 };
6656 let frame = cap_plane.frame();
6657 for edges in &loop_edges {
6658 for edge in edges {
6659 let (curve, range) = spine_curve_of(model, edge)?;
6660 let ogeom_geom::Curve::BSpline(bs) = &curve else {
6661 ogeom_bail!(Construction, "a swept ring is not a spline");
6662 };
6663 let control2: Vec<Point2> = bs
6666 .control_points()
6667 .iter()
6668 .map(|w| {
6669 let local = frame.to_local(w.point());
6670 Point2::new(local.x, local.y)
6671 })
6672 .collect();
6673 let pcurve: ogeom_geom::PlanarCurve =
6674 ogeom_geom::BSpline2d::new(bs.knots().clone(), control2, tol)?.into();
6675 ogeom_algo::attach_pcurve(
6676 model,
6677 edge,
6678 pcurve,
6679 cap_id,
6680 ogeom_topo::Location::identity(),
6681 range,
6682 )?;
6683 }
6684 }
6685 faces.push(face);
6686 }
6687
6688 let sewn = sew(model, &faces, tol)?;
6689 let mut built = if ring {
6690 if sewn.shells.is_empty() {
6694 ogeom_bail!(Construction, "the pipe shell did not close");
6695 }
6696 for shell in &sewn.shells {
6697 if !ogeom_algo::is_shell_closed(model, shell)? {
6698 ogeom_bail!(Construction, "the pipe shell did not close");
6699 }
6700 }
6701 let mut ordered = sewn.shells.clone();
6702 let mut sized: Vec<(f64, Shape)> = Vec::with_capacity(ordered.len());
6703 for shell in ordered.drain(..) {
6704 let bound = ogeom_algo::shape_bounds(model, &shell, tol)?;
6705 sized.push((bound.diagonal(), shell));
6706 }
6707 sized.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(core::cmp::Ordering::Equal));
6708 let shells: Vec<Shape> = sized.into_iter().map(|(_, s)| s).collect();
6709 make_solid(model, &shells)?
6710 } else {
6711 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
6712 if std::env::var_os("OGEOM_DEBUG_SWEEP").is_some() {
6713 eprintln!(
6714 "SWEEP: {} shells, {} free edges from {} faces",
6715 sewn.shells.len(),
6716 sewn.free_edges.len(),
6717 faces.len()
6718 );
6719 for edge in &sewn.free_edges {
6720 let (curve, range) = spine_curve_of(model, edge)?;
6721 let a = curve.point_at(range.0, tol)?;
6722 let b = curve.point_at(range.1, tol)?;
6723 let m = curve.point_at(f64::midpoint(range.0, range.1), tol)?;
6724 let t = model.tolerance_of(edge)?.map_or(0.0, |t| t.get());
6725 eprintln!(
6726 " free ({:.3},{:.3},{:.3}) -> ({:.3},{:.3},{:.3}) via ({:.3},{:.3},{:.3}) tol {t:.2e}",
6727 a.x, a.y, a.z, b.x, b.y, b.z, m.x, m.y, m.z
6728 );
6729 }
6730 }
6731 ogeom_bail!(Construction, "the pipe shell did not close");
6732 }
6733 make_solid(model, std::slice::from_ref(&sewn.shells[0]))?
6734 };
6735 built.history.generate(profile, built.shape.clone());
6736 built.history.generate(spine, built.shape.clone());
6737 Ok(built)
6738}
6739
6740#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
6753fn mitred_pieces(
6754 model: &mut Model,
6755 profile: &Shape,
6756 spine: &Shape,
6757 stations: &[SpineStation],
6758 skew: &[(usize, usize)],
6759 ring: bool,
6760 probes: &[(f64, f64)],
6761 tolerance: f64,
6762 tol: Tolerances,
6763) -> OgeomResult<Built> {
6764 if model.kind_of(profile)? == ShapeType::Wire {
6765 if !ogeom_algo::is_wire_closed(model, profile, tol)? {
6769 ogeom_bail!(
6770 Construction,
6771 "a skew corner against a curved leg is mitred by fusing solid \
6772 pieces; an open wire bounds no face to sweep round it"
6773 );
6774 }
6775 let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
6776 ogeom_bail!(Construction, "a pipe shell sweeps a planar profile");
6777 };
6778 let reach = 1e4_f64;
6779 let surface: SurfaceGeometry =
6780 PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
6781 let face = ogeom_algo::make_face(model, surface, std::slice::from_ref(profile), tol)?.shape;
6782 let face = realized_profile(model, &face, &Transform::IDENTITY, tol)?;
6783 let solid = mitred_pieces(
6784 model, &face, spine, stations, skew, ring, probes, tolerance, tol,
6785 )?
6786 .shape;
6787 let ends: Vec<(Point, Vector)> = if ring {
6788 Vec::new()
6789 } else {
6790 vec![
6791 (stations[0].at, stations[0].tangent),
6792 (
6793 stations[stations.len() - 1].at,
6794 stations[stations.len() - 1].tangent,
6795 ),
6796 ]
6797 };
6798 let mut walls = Vec::new();
6799 for f in explore(model, &solid, Filter::OfType(ShapeType::Face))? {
6800 let Some(ogeom_topo::NodeData::Face(data)) = model.node(&f).map(|n| n.data()) else {
6801 continue;
6802 };
6803 let cap = match model.geometry().surface(data.surface) {
6804 Some(SurfaceGeometry::Plane(p)) => {
6805 let placed = p.plane();
6806 ends.iter().any(|(at, n)| {
6807 placed.normal().vector().cross(*n).magnitude() <= tol.angular()
6808 && placed.distance_to(*at) <= tol.confusion() * 100.0
6809 })
6810 }
6811 _ => false,
6812 };
6813 if !cap {
6814 walls.push(f);
6815 }
6816 }
6817 let sewn = sew(model, &walls, tol)?;
6818 let shape = match sewn.shells.as_slice() {
6819 [shell] => shell.clone(),
6820 _ => ogeom_algo::make_compound(model, &sewn.shells)?.shape,
6821 };
6822 let mut history = History::new();
6823 history.generate(profile, shape.clone());
6824 history.generate(spine, shape.clone());
6825 return Ok(Built::new(shape, history));
6826 }
6827 if model.kind_of(profile)? != ShapeType::Face {
6828 ogeom_bail!(Construction, "a pipe shell sweeps a planar wire or face");
6829 }
6830 let edges: Vec<Shape> = match model.kind_of(spine)? {
6831 ShapeType::Edge => vec![spine.clone()],
6832 _ => model.ordered_children_of(spine)?,
6833 };
6834 let normals = rmf_normals(stations);
6835 let reach_out = probes
6836 .iter()
6837 .map(|(a, b)| a.hypot(*b))
6838 .fold(0.0_f64, f64::max);
6839
6840 struct Split {
6844 edge_before: usize,
6845 at: Point,
6846 before: Vector,
6847 after: Vector,
6848 frame_after: Vector,
6849 run_on: f64,
6850 }
6851 let mut splits: Vec<Split> = Vec::with_capacity(skew.len());
6852 for &(k, next) in skew {
6853 let (before, after) = (stations[k].tangent, stations[next].tangent);
6854 let turn = before.dot(after).clamp(-1.0, 1.0).acos();
6855 let half = (turn * 0.5).cos();
6856 if half < 0.05 {
6857 ogeom_bail!(
6858 Construction,
6859 "the spine all but doubles back at a corner; a mitre there \
6860 runs off to infinity"
6861 );
6862 }
6863 splits.push(Split {
6864 edge_before: stations[k].edge,
6865 at: stations[k].at,
6866 before,
6867 after,
6868 frame_after: normals[next],
6869 run_on: reach_out * ((turn * 0.5).tan() * 1.5 + 0.1),
6873 });
6874 }
6875 splits.sort_by_key(|s| s.edge_before);
6876
6877 let count = edges.len();
6880 let mut pieces: Vec<(Vec<usize>, Option<usize>, Option<usize>)> = Vec::new();
6881 if ring {
6882 for (i, split) in splits.iter().enumerate() {
6883 let next = &splits[(i + 1) % splits.len()];
6884 let mut run = Vec::new();
6885 let mut e = (split.edge_before + 1) % count;
6886 loop {
6887 run.push(e);
6888 if e == next.edge_before {
6889 break;
6890 }
6891 e = (e + 1) % count;
6892 }
6893 pieces.push((run, Some(i), Some((i + 1) % splits.len())));
6894 }
6895 } else {
6896 let mut first = 0;
6897 for (i, split) in splits.iter().enumerate() {
6898 pieces.push((
6899 (first..=split.edge_before).collect(),
6900 i.checked_sub(1),
6901 Some(i),
6902 ));
6903 first = split.edge_before + 1;
6904 }
6905 pieces.push(((first..count).collect(), splits.len().checked_sub(1), None));
6906 }
6907
6908 let x0 = normals[0];
6909 let start_frame = Frame::new(
6910 stations[0].at,
6911 Direction::new(stations[0].tangent, tol)?,
6912 Direction::new(x0, tol)?,
6913 tol,
6914 )?;
6915 let mut blocks: Vec<Shape> = Vec::with_capacity(splits.len());
6921 for split in &splits {
6922 let n = (split.before + split.after) / (split.before + split.after).magnitude();
6923 let normal = Direction::new(n, tol)?;
6924 let plane = Plane::through(split.at, normal);
6925 let reach = (split.run_on + reach_out) * 4.0;
6926 let frame = plane.frame();
6927 let (u, v) = (frame.x().vector(), frame.y().vector());
6928 let corners: Vec<Point> = [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
6929 .iter()
6930 .map(|(a, b)| split.at + u * (a * reach) + v * (b * reach))
6931 .collect();
6932 let wire = ogeom_algo::make_polygon(model, &corners, true, tol)?.shape;
6933 let edges = explore(model, &wire, Filter::OfType(ShapeType::Edge))?;
6934 let surface: SurfaceGeometry = PlaneSurface::over(
6935 plane,
6936 (-reach * 2.0, reach * 2.0),
6937 (-reach * 2.0, reach * 2.0),
6938 )?
6939 .into();
6940 let base = ogeom_algo::make_face_with_pcurves(model, surface, &[edges], tol)?.shape;
6941 let block = ogeom_algo::make_prism(model, &base, n * (reach * 2.0), tol)?.shape;
6942 blocks.push(block);
6943 }
6944
6945 let mut joined: Vec<Shape> = Vec::new();
6946 for (run, start, end) in pieces {
6947 let mut wire_edges: Vec<Shape> = Vec::new();
6948 let traversal = |model: &Model, e: usize, at_start: bool| -> OgeomResult<Shape> {
6949 let Some((a, b)) = ogeom_algo::edge_vertices(model, &edges[e])? else {
6952 ogeom_bail!(Construction, "a spine edge has no vertices");
6953 };
6954 Ok(if at_start { a } else { b })
6955 };
6956 if let Some(i) = start {
6957 let split = &splits[i];
6958 let far = ogeom_algo::make_vertex(model, split.at - split.after * split.run_on).shape;
6959 let near = traversal(model, run[0], true)?;
6960 let line: ogeom_geom::Curve =
6961 LineCurve::segment(split.at - split.after * split.run_on, split.at, tol)?.into();
6962 let domain = line.domain();
6963 wire_edges
6964 .push(ogeom_algo::make_edge_between(model, line, domain, &far, &near, tol)?.shape);
6965 }
6966 wire_edges.extend(run.iter().map(|&e| edges[e].clone()));
6967 if let Some(i) = end {
6968 let split = &splits[i];
6969 let near = traversal(model, run[run.len() - 1], false)?;
6970 let far = ogeom_algo::make_vertex(model, split.at + split.before * split.run_on).shape;
6971 let line: ogeom_geom::Curve =
6972 LineCurve::segment(split.at, split.at + split.before * split.run_on, tol)?.into();
6973 let domain = line.domain();
6974 wire_edges
6975 .push(ogeom_algo::make_edge_between(model, line, domain, &near, &far, tol)?.shape);
6976 }
6977 let sub_spine = ogeom_algo::make_wire(model, &wire_edges, tol)?.shape;
6978 let placed = match start {
6983 None => profile.clone(),
6984 Some(i) => {
6985 let split = &splits[i];
6986 let target = Frame::new(
6987 split.at - split.after * split.run_on,
6988 Direction::new(split.after, tol)?,
6989 Direction::new(split.frame_after, tol)?,
6990 tol,
6991 )?;
6992 let motion = Transform::from_frame(&target) * Transform::to_frame(&start_frame);
6993 realized_profile(model, profile, &motion, tol)?
6994 }
6995 };
6996 let mut piece = make_pipe_shell(model, &placed, &sub_spine, false, tolerance, tol)?.shape;
6997 if model.kind_of(&piece)? != ShapeType::Solid {
6998 ogeom_bail!(Construction, "a mitred piece did not sweep into a solid");
6999 }
7000 if let Some(i) = start {
7001 piece = ogeom_bool::common(model, &piece, &blocks[i], tol)?.shape;
7002 }
7003 if let Some(i) = end {
7004 piece = ogeom_bool::cut(model, &piece, &blocks[i], tol)?.shape;
7005 }
7006 joined.push(piece);
7007 }
7008 let result = fuse_in_order(model, joined, tol)?;
7009 let Some(shape) = result else {
7010 ogeom_bail!(Construction, "the spine produced no piece to sweep");
7011 };
7012 let mut history = History::new();
7013 history.generate(profile, shape.clone());
7014 for edge in &edges {
7015 history.generate(edge, shape.clone());
7016 }
7017 Ok(Built::new(shape, history))
7018}
7019
7020fn realized_profile(
7023 model: &mut Model,
7024 profile: &Shape,
7025 motion: &Transform,
7026 tol: Tolerances,
7027) -> OgeomResult<Shape> {
7028 realized_profile_wound(model, profile, motion, None, tol)
7029}
7030
7031fn realized_profile_wound(
7034 model: &mut Model,
7035 profile: &Shape,
7036 motion: &Transform,
7037 about: Option<Vector>,
7038 tol: Tolerances,
7039) -> OgeomResult<Shape> {
7040 use ogeom_geom::Transformable as _;
7041 let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
7042 ogeom_bail!(Construction, "a pipe shell sweeps a planar profile");
7043 };
7044 let moved_plane = Plane::through(
7045 motion.apply(plane.origin()),
7046 Direction::new(motion.apply_vector(plane.normal().vector()), tol)?,
7047 );
7048 let mut vertices: ogeom_core::FastMap<ogeom_topo::TShapeId, Shape> =
7049 ogeom_core::FastMap::default();
7050 let mut edge_copies: ogeom_core::FastMap<ogeom_topo::TShapeId, Shape> =
7051 ogeom_core::FastMap::default();
7052 let mut wires: Vec<Vec<Shape>> = Vec::new();
7053 for wire in explore(model, profile, Filter::OfType(ShapeType::Wire))? {
7054 let mut ring = Vec::new();
7055 for edge in model.ordered_children_of(&wire)? {
7056 let copy = match edge_copies.get(&edge.node()) {
7057 Some(done) => done.clone(),
7058 None => {
7059 let (curve, range) = spine_curve_of(model, &edge)?;
7060 let placed = curve.transformed(&edge.transform(model.datums())?, tol)?;
7061 let moved = placed.transformed(motion, tol)?;
7062 let own = if edge.orientation() == ogeom_topo::Orientation::Reversed {
7065 edge.reversed()
7066 } else {
7067 edge.clone()
7068 };
7069 let Some((a, b)) = ogeom_algo::edge_vertices(model, &own)? else {
7070 ogeom_bail!(Construction, "a profile edge has no vertices");
7071 };
7072 let mut ends = Vec::with_capacity(2);
7073 for v in [a, b] {
7074 let key = v.node();
7075 let held = match vertices.get(&key) {
7076 Some(done) => done.clone(),
7077 None => {
7078 let Some(data) = model.node(&v).and_then(|n| n.data().as_vertex())
7079 else {
7080 ogeom_bail!(Construction, "a profile vertex holds no data");
7081 };
7082 let at = v.transform(model.datums())?.apply(data.point);
7083 let fresh = ogeom_algo::make_vertex(model, motion.apply(at)).shape;
7084 vertices.insert(key, fresh.clone());
7085 fresh
7086 }
7087 };
7088 ends.push(held);
7089 }
7090 let fresh = ogeom_algo::make_edge_between(
7091 model, moved, range, &ends[0], &ends[1], tol,
7092 )?
7093 .shape;
7094 edge_copies.insert(edge.node(), fresh.clone());
7095 fresh
7096 }
7097 };
7098 ring.push(if edge.orientation() == ogeom_topo::Orientation::Reversed {
7099 copy.reversed()
7100 } else {
7101 copy
7102 });
7103 }
7104 if let Some(axis) = about {
7105 let turning = ring_turning(model, &ring, axis, tol)?;
7106 let outer = wires.is_empty();
7107 if (turning > 0.0) != outer {
7108 ring = ring.iter().rev().map(Shape::reversed).collect();
7109 }
7110 }
7111 wires.push(ring);
7112 }
7113 let reach = 1e4_f64;
7114 let surface: SurfaceGeometry =
7115 PlaneSurface::over(moved_plane, (-reach, reach), (-reach, reach))?.into();
7116 Ok(ogeom_algo::make_face_with_pcurves(model, surface, &wires, tol)?.shape)
7117}
7118
7119fn walked_about(
7124 model: &Model,
7125 ring: &[Shape],
7126 axis: Vector,
7127 outer: bool,
7128 tol: Tolerances,
7129) -> OgeomResult<Vec<Shape>> {
7130 let turn = ring_turning(model, ring, axis, tol)?;
7131 Ok(if (turn > 0.0) == outer {
7132 ring.to_vec()
7133 } else {
7134 walked_back(ring)
7135 })
7136}
7137
7138fn walked_back(ring: &[Shape]) -> Vec<Shape> {
7141 ring.iter().rev().map(Shape::reversed).collect()
7142}
7143
7144fn ring_turning(model: &Model, ring: &[Shape], axis: Vector, tol: Tolerances) -> OgeomResult<f64> {
7147 let mut points: Vec<Point> = Vec::new();
7148 for edge in ring {
7149 let (curve, range) = spine_curve_of(model, edge)?;
7150 let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
7151 for i in 0..32 {
7152 let f = f64::from(i) / 32.0;
7153 let t = if reversed {
7154 range.1 - (range.1 - range.0) * f
7155 } else {
7156 range.0 + (range.1 - range.0) * f
7157 };
7158 points.push(curve.point_at(t, tol)?);
7159 }
7160 }
7161 let Some(&origin) = points.first() else {
7162 return Ok(0.0);
7163 };
7164 let n = points.len();
7165 Ok((0..n)
7166 .map(|i| {
7167 (points[i] - origin)
7168 .cross(points[(i + 1) % n] - origin)
7169 .dot(axis)
7170 })
7171 .sum())
7172}
7173
7174pub(crate) fn spine_curve_of(
7175 model: &Model,
7176 edge: &Shape,
7177) -> OgeomResult<(ogeom_geom::Curve, (f64, f64))> {
7178 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
7179 ogeom_bail!(Construction, "an edge holds no data");
7180 };
7181 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
7182 ogeom_bail!(Construction, "an edge has no curve");
7183 };
7184 let Some(geometry) = model.geometry().curve(*curve) else {
7185 ogeom_bail!(Dangling, "curve is not in this model");
7186 };
7187 Ok((geometry.clone(), *range))
7188}
7189
7190fn closed_pipe_shell(
7200 model: &mut Model,
7201 profile: &Shape,
7202 spine: &Shape,
7203 mut stations: Vec<SpineStation>,
7204 frenet: bool,
7205 tolerance: f64,
7206 tol: Tolerances,
7207) -> OgeomResult<Built> {
7208 let Some(home) = stations.pop() else {
7210 ogeom_bail!(Construction, "a closed spine needs room to turn");
7211 };
7212 if stations.len() < 3 {
7213 ogeom_bail!(Construction, "a closed spine needs room to turn");
7214 }
7215 for i in 0..stations.len() {
7218 let next = &stations[(i + 1) % stations.len()];
7219 if stations[i].tangent.dot(next.tangent) < 0.9 {
7220 ogeom_bail!(
7221 Construction,
7222 "a closed spine turns too sharply between two of its stations to skin"
7223 );
7224 }
7225 }
7226
7227 let loops: Vec<Shape> = match model.kind_of(profile)? {
7228 ShapeType::Face => explore(model, profile, Filter::OfType(ShapeType::Wire))?,
7229 ShapeType::Wire => vec![profile.clone()],
7230 other => ogeom_bail!(
7231 Construction,
7232 "a pipe shell sweeps a planar wire or face, not a {other:?}"
7233 ),
7234 };
7235 let profile_loop = &loops[0];
7238 let edges = model.ordered_children_of(profile_loop)?;
7239 let smooth = edges.len() == 1
7240 && ogeom_algo::edge_vertices(model, &edges[0])?.is_some_and(|(a, b)| a.is_same(&b));
7241 let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
7242 ogeom_bail!(Construction, "a pipe shell sweeps a planar profile");
7243 };
7244 let t0 = stations[0].tangent;
7245 let exact_t0 = {
7248 let first = match model.kind_of(spine)? {
7249 ShapeType::Edge => spine.clone(),
7250 _ => model.ordered_children_of(spine)?[0].clone(),
7251 };
7252 let (curve, range) = spine_curve_of(model, &first)?;
7253 let reversed = first.orientation() == ogeom_topo::Orientation::Reversed;
7254 let d = curve.d1_at(if reversed { range.1 } else { range.0 }, tol)?;
7255 let d = if reversed { -d } else { d };
7256 d / d.magnitude()
7257 };
7258 if plane.normal().vector().cross(exact_t0).magnitude() > tol.angular() {
7259 ogeom_bail!(
7260 Construction,
7261 "the profile leans along its spine; a pipe shell runs square to \
7262 the start"
7263 );
7264 }
7265 if plane.distance_to(stations[0].at) > tol.confusion() * 100.0 {
7266 ogeom_bail!(
7267 Construction,
7268 "the profile does not sit at the spine's start"
7269 );
7270 }
7271
7272 let mut normals: Vec<Vector> = if frenet {
7279 frenet_normals_closed(&stations, tol)?
7286 } else {
7287 let mut extended = stations.clone();
7288 extended.push(stations[0]);
7289 let carried = rmf_normals(&extended);
7290 let (n0, n_home) = (carried[0], carried[carried.len() - 1]);
7291 let twist = (n0.cross(n_home).dot(t0)).atan2(n0.dot(n_home));
7292 let mut lengths = vec![0.0_f64];
7293 for pair in extended.windows(2) {
7294 let last = lengths[lengths.len() - 1];
7295 lengths.push(last + pair[0].at.distance(pair[1].at));
7296 }
7297 let total = lengths[lengths.len() - 1];
7298 carried
7299 .iter()
7300 .take(stations.len())
7301 .enumerate()
7302 .map(|(i, n)| {
7303 let phi = -twist * lengths[i] / total;
7304 let t = extended[i].tangent;
7305 *n * phi.cos() + t.cross(*n) * phi.sin()
7306 })
7307 .collect()
7308 };
7309 for (n, station) in normals.iter_mut().zip(&stations) {
7310 let v = *n - station.tangent * n.dot(station.tangent);
7312 *n = v / v.magnitude();
7313 }
7314
7315 let x0 = normals[0];
7316 let origin = stations[0].at;
7317 let (round, round_normals) = {
7320 let mut round = stations.clone();
7321 round.push(SpineStation {
7322 tangent: stations[0].tangent,
7323 ..home
7324 });
7325 let mut normals = normals.clone();
7326 normals.push(normals[0]);
7327 (round, normals)
7328 };
7329 let walk = SpineWalk {
7330 curves: walk_curves(model, spine)?,
7331 stations: &round,
7332 normals: &round_normals,
7333 };
7334 let mut shells: Vec<Shape> = Vec::with_capacity(loops.len());
7335 for (li, wire) in loops.iter().enumerate() {
7336 let wire_edges = model.ordered_children_of(wire)?;
7337 let wire_smooth = wire_edges.len() == 1
7338 && ogeom_algo::edge_vertices(model, &wire_edges[0])?
7339 .is_some_and(|(a, b)| a.is_same(&b));
7340 let shell = closed_loop_shell(
7341 model,
7342 wire,
7343 &wire_edges,
7344 wire_smooth,
7345 &walk,
7346 (origin, x0),
7347 tolerance,
7348 tol,
7349 )?;
7350 let enclosed = shell_signed_volume(model, &shell, tol)?;
7354 let outward = enclosed > 0.0;
7355 shells.push(if outward == (li == 0) {
7356 shell
7357 } else {
7358 shell.reversed()
7359 });
7360 }
7361 let mut built = make_solid(model, &shells)?;
7362 built.history.generate(profile, built.shape.clone());
7363 built.history.generate(spine, built.shape.clone());
7364 let _ = (smooth, profile_loop, edges);
7365 Ok(built)
7366}
7367
7368fn shell_signed_volume(model: &Model, shell: &Shape, tol: Tolerances) -> OgeomResult<f64> {
7371 Ok(ogeom_mesh::triangulate(model, shell, ogeom_mesh::Deflection::default(), tol)?.volume())
7372}
7373
7374fn shell_stations(model: &Model, spine: &Shape, tol: Tolerances) -> OgeomResult<Vec<SpineStation>> {
7377 let edges: Vec<Shape> = match model.kind_of(spine)? {
7378 ShapeType::Edge => vec![spine.clone()],
7379 ShapeType::Wire => model.ordered_children_of(spine)?,
7380 other => ogeom_bail!(
7381 Construction,
7382 "a pipe shell runs along an edge or a wire, not a {other:?}"
7383 ),
7384 };
7385 if edges.is_empty() {
7386 ogeom_bail!(Construction, "the spine has no edge to run along");
7387 }
7388 let mut stations: Vec<SpineStation> = Vec::new();
7389 for (ei, edge) in edges.iter().enumerate() {
7390 let (curve, range) = {
7391 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
7392 ogeom_bail!(Construction, "a spine edge holds no data");
7393 };
7394 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
7395 ogeom_bail!(Construction, "a spine edge has no curve");
7396 };
7397 let Some(geometry) = model.geometry().curve(*curve) else {
7398 ogeom_bail!(Dangling, "curve is not in this model");
7399 };
7400 (geometry.clone(), *range)
7401 };
7402 let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
7403 let turning = {
7407 let mut sum = 0.0_f64;
7408 let mut last: Option<Vector> = None;
7409 for i in 0..=16 {
7410 let t = range.0 + (range.1 - range.0) * f64::from(i) / 16.0;
7411 let d = curve.d1_at(t, tol)?;
7412 let m = d.magnitude();
7413 if m <= tol.confusion() {
7414 continue;
7415 }
7416 let u = d / m;
7417 if let Some(prev) = last {
7418 sum += prev.dot(u).clamp(-1.0, 1.0).acos() * 16.0 / 16.0;
7419 }
7420 last = Some(u);
7421 }
7422 sum
7423 };
7424 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
7425 let count = (turning / (core::f64::consts::TAU / 64.0)).ceil().max(8.0) as usize;
7426 for i in 0..=count {
7427 #[allow(clippy::cast_precision_loss)]
7428 let f = (i as f64) / (count as f64);
7429 let t = if reversed {
7430 range.1 - (range.1 - range.0) * f
7431 } else {
7432 range.0 + (range.1 - range.0) * f
7433 };
7434 let p = curve.point_at(t, tol)?;
7435 let d = curve.d1_at(t, tol)?;
7436 let m = d.magnitude();
7437 if m <= tol.confusion() {
7438 ogeom_bail!(Construction, "the spine is degenerate at {t}");
7439 }
7440 let tangent = if reversed { -(d / m) } else { d / m };
7441 if let Some(prev) = stations.last()
7442 && prev.edge == ei
7443 && prev.at.distance(p) <= tol.confusion()
7444 && prev.tangent.cross(tangent).magnitude() <= tol.angular()
7445 && prev.tangent.dot(tangent) > 0.0
7446 {
7447 continue;
7448 }
7449 stations.push(SpineStation {
7455 at: p,
7456 tangent,
7457 edge: ei,
7458 t,
7459 });
7460 }
7461 }
7462 if stations.len() < 2 {
7463 ogeom_bail!(Construction, "the spine collapses to a point");
7464 }
7465 Ok(stations)
7466}
7467
7468fn frenet_normals(stations: &[SpineStation], tol: Tolerances) -> OgeomResult<Vec<Vector>> {
7473 let mut normals: Vec<Option<Vector>> = Vec::with_capacity(stations.len());
7474 for i in 0..stations.len() {
7475 let (before, after) = (
7476 &stations[i.saturating_sub(1)],
7477 &stations[(i + 1).min(stations.len() - 1)],
7478 );
7479 let dt = after.tangent - before.tangent;
7480 let t = stations[i].tangent;
7481 let bend = dt - t * dt.dot(t);
7482 let m = bend.magnitude();
7483 normals.push(if m > tol.angular().max(1e-9) {
7484 Some(bend / m)
7485 } else {
7486 None
7487 });
7488 }
7489 let mut carried: Vec<Vector> = Vec::with_capacity(stations.len());
7492 let mut last: Option<Vector> = None;
7493 for n in &normals {
7494 if let Some(n) = n {
7495 last = Some(*n);
7496 }
7497 carried.push(last.unwrap_or(Vector::new(0.0, 0.0, 0.0)));
7498 }
7499 let mut ahead: Option<Vector> = None;
7500 for i in (0..stations.len()).rev() {
7501 if let Some(n) = normals[i] {
7502 ahead = Some(n);
7503 } else if carried[i].magnitude() < 0.5
7504 && let Some(n) = ahead
7505 {
7506 carried[i] = n;
7507 }
7508 }
7509 if carried.iter().any(|n| n.magnitude() < 0.5) {
7510 ogeom_bail!(
7511 Construction,
7512 "a straight spine has no Frenet frame; use the \
7513 rotation-minimizing default"
7514 );
7515 }
7516 Ok(carried)
7517}
7518
7519fn frenet_normals_closed(stations: &[SpineStation], tol: Tolerances) -> OgeomResult<Vec<Vector>> {
7524 let n = stations.len();
7525 let mut normals: Vec<Option<Vector>> = Vec::with_capacity(n);
7526 for i in 0..n {
7527 let (before, after) = (&stations[(i + n - 1) % n], &stations[(i + 1) % n]);
7528 let dt = after.tangent - before.tangent;
7529 let t = stations[i].tangent;
7530 let bend = dt - t * dt.dot(t);
7531 let m = bend.magnitude();
7532 normals.push(if m > tol.angular().max(1e-9) {
7533 Some(bend / m)
7534 } else {
7535 None
7536 });
7537 }
7538 let Some(first_bend) = normals.iter().position(Option::is_some) else {
7539 ogeom_bail!(
7540 Construction,
7541 "a straight spine has no Frenet frame; use the \
7542 rotation-minimizing default"
7543 );
7544 };
7545 let mut carried: Vec<Vector> = vec![Vector::new(0.0, 0.0, 0.0); n];
7548 let mut last = normals[first_bend].unwrap_or_else(|| unreachable!());
7549 for k in 0..n {
7550 let i = (first_bend + k) % n;
7551 if let Some(bend) = normals[i] {
7552 last = bend;
7553 }
7554 carried[i] = last;
7555 }
7556 Ok(carried)
7557}
7558
7559struct Station {
7564 from: Point,
7566 to: Point,
7567 tangent_in: Vector,
7569 tangent_out: Vector,
7570 turn: Option<(ogeom_math::Axis, f64)>,
7572}
7573
7574pub fn make_evolved(
7615 model: &mut Model,
7616 spine: &Shape,
7617 profile: &Shape,
7618 tol: Tolerances,
7619) -> OgeomResult<Built> {
7620 use ogeom_algo::{make_prism, make_revolution, transformed};
7621
7622 let (wire, capped_by_spine_plane) = match model.kind_of(spine)? {
7623 ShapeType::Face => {
7624 let wires = explore(model, spine, Filter::OfType(ShapeType::Wire))?;
7625 let Some(outer) = wires.first().cloned() else {
7626 ogeom_bail!(Construction, "a face with no wire has no spine to run");
7627 };
7628 (outer, true)
7629 }
7630 ShapeType::Wire => (spine.clone(), false),
7631 other => ogeom_bail!(
7632 Construction,
7633 "a {other:?} is not a spine; sweep along a wire or a planar face"
7634 ),
7635 };
7636
7637 let stations = spine_stations(model, &wire, tol)?;
7638 if stations.is_empty() {
7639 ogeom_bail!(Construction, "a spine with no edges goes nowhere");
7640 }
7641 let normal = spine_normal(&stations, tol)?;
7642 let (profile_origin, profile_normal) = profile_plane(model, profile, tol)?;
7643 if profile_normal.dot(normal.vector()).abs() > tol.angular() {
7644 ogeom_bail!(
7645 Construction,
7646 "the profile's plane must contain the spine's normal, or the \
7647 profile is not square to the spine it travels"
7648 );
7649 }
7650 let start = &stations[0];
7651 if profile_normal.cross(start.tangent_in).magnitude() > tol.angular() {
7652 ogeom_bail!(
7653 Construction,
7654 "the profile's plane must cut the spine across, not run along it: \
7655 the profile is not square to the spine it travels"
7656 );
7657 }
7658 let _ = profile_origin;
7659 let reference = (start.from, start.tangent_in);
7660
7661 let section = profile_face(
7666 model,
7667 profile,
7668 profile_normal,
7669 capped_by_spine_plane.then(|| Plane::through(start.from, normal)),
7670 tol,
7671 )?;
7672
7673 let mut pieces: Vec<Shape> = Vec::new();
7674 for (index, station) in stations.iter().enumerate() {
7675 if index > 0 {
7678 let previous = &stations[index - 1];
7679 if let Some(piece) = corner_piece(
7680 model,
7681 §ion,
7682 reference,
7683 previous.to,
7684 previous.tangent_out,
7685 station.tangent_in,
7686 normal,
7687 tol,
7688 )? {
7689 pieces.push(piece);
7690 }
7691 }
7692 let placed = transformed(
7693 model,
7694 §ion,
7695 station_transform(reference, station.from, station.tangent_in, normal, tol)?,
7696 )?
7697 .shape;
7698 pieces.push(match station.turn {
7699 None => make_prism(model, &placed, station.to - station.from, tol)?.shape,
7700 Some((axis, angle)) => make_revolution(model, &placed, axis, angle, tol)?.shape,
7701 });
7702 }
7703 let last = &stations[stations.len() - 1];
7706 if last.to.distance(start.from) <= tol.confusion()
7707 && let Some(piece) = corner_piece(
7708 model,
7709 §ion,
7710 reference,
7711 last.to,
7712 last.tangent_out,
7713 start.tangent_in,
7714 normal,
7715 tol,
7716 )?
7717 {
7718 pieces.push(piece);
7719 }
7720
7721 let mut history = History::new();
7725 let Some(shape) = fuse_in_order(model, pieces, tol)? else {
7726 ogeom_bail!(Construction, "the spine produced no piece to sweep");
7727 };
7728 history.generate(spine, shape.clone());
7729 history.generate(profile, shape.clone());
7730 Ok(Built::new(shape, history))
7731}
7732
7733fn profile_face(
7741 model: &mut Model,
7742 profile: &Shape,
7743 profile_normal: Vector,
7744 against: Option<Plane>,
7745 tol: Tolerances,
7746) -> OgeomResult<Shape> {
7747 if model.kind_of(profile)? == ShapeType::Face {
7748 return Ok(profile.clone());
7749 }
7750 if model.kind_of(profile)? != ShapeType::Wire {
7751 ogeom_bail!(Construction, "a profile is a wire or a face");
7752 }
7753 let mut edges = ogeom_topo::explore(model, profile, Filter::OfType(ShapeType::Edge))?;
7754 let closed = ogeom_algo::is_wire_closed(model, profile, tol)?;
7755 if !closed {
7756 let Some(plane) = against else {
7757 ogeom_bail!(
7758 Construction,
7759 "an open profile sweeps a shell, not a volume; give the spine \
7760 as a planar face for its plane to close the profile against, \
7761 or close the profile itself"
7762 );
7763 };
7764 let [(from, v0), (to, v1)] = wire_ends(model, profile, tol)?;
7765 for end in [from, to] {
7766 if plane.signed_distance_to(end).abs() > tol.confusion() * 1e2 {
7767 ogeom_bail!(
7768 Construction,
7769 "an open profile is closed against the spine face's own \
7770 plane, and this one does not reach it"
7771 );
7772 }
7773 }
7774 let line = LineCurve::new(ogeom_math::Axis {
7777 location: from,
7778 direction: Direction::new(to - from, tol)?,
7779 });
7780 edges.push(
7781 make_edge_between(model, line.into(), (0.0, from.distance(to)), &v0, &v1, tol)?.shape,
7782 );
7783 }
7784 let ordered = ogeom_algo::order_edges(model, &edges, tol)?;
7785 let mut bound = ogeom_math::Aabb::EMPTY;
7786 for edge in &ordered {
7787 bound = bound.union(&ogeom_algo::shape_bounds(model, edge, tol)?);
7788 }
7789 let Some(centre) = bound.centre() else {
7790 ogeom_bail!(Construction, "a profile with no extent sweeps nothing");
7791 };
7792 let reach = bound.diagonal().mul_add(2.0, 1.0);
7793 let plane = Plane::through(centre, Direction::new(profile_normal, tol)?);
7794 let surface = PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?;
7795 Ok(make_face_with_pcurves(model, surface.into(), &[ordered], tol)?.shape)
7796}
7797
7798fn wire_ends(model: &Model, wire: &Shape, tol: Tolerances) -> OgeomResult<[(Point, Shape); 2]> {
7800 let mut counts: Vec<(Point, Shape, usize)> = Vec::new();
7801 for edge in explore(model, wire, Filter::OfType(ShapeType::Edge))? {
7802 for v in explore(model, &edge, Filter::OfType(ShapeType::Vertex))? {
7803 let Some(data) = model.node(&v).and_then(|n| n.data().as_vertex()) else {
7804 continue;
7805 };
7806 let at = v.transform(model.datums())?.apply(data.point);
7807 match counts
7808 .iter_mut()
7809 .find(|(p, _, _)| p.distance(at) <= tol.confusion() * 10.0)
7810 {
7811 Some((_, _, n)) => *n += 1,
7812 None => counts.push((at, v.clone(), 1)),
7813 }
7814 }
7815 }
7816 let free: Vec<(Point, Shape)> = counts
7817 .into_iter()
7818 .filter(|(_, _, n)| *n == 1)
7819 .map(|(p, v, _)| (p, v))
7820 .collect();
7821 if free.len() != 2 {
7822 ogeom_bail!(
7823 Construction,
7824 "an open profile has exactly two ends; this one has {}",
7825 free.len()
7826 );
7827 }
7828 let mut ends = free.into_iter();
7829 let (Some(a), Some(b)) = (ends.next(), ends.next()) else {
7830 ogeom_bail!(Construction, "the profile lost an end between checks");
7831 };
7832 Ok([a, b])
7833}
7834
7835fn spine_stations(model: &Model, wire: &Shape, tol: Tolerances) -> OgeomResult<Vec<Station>> {
7837 let mut out = Vec::new();
7838 for edge in explore(model, wire, Filter::OfType(ShapeType::Edge))? {
7839 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
7840 ogeom_bail!(Construction, "a spine edge is not in this model");
7841 };
7842 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
7843 ogeom_bail!(Construction, "a spine edge with no curve runs nowhere");
7844 };
7845 let Some(geometry) = model.geometry().curve(*curve) else {
7846 ogeom_bail!(Dangling, "curve is not in this model");
7847 };
7848 let placed = geometry.transformed(&edge.transform(model.datums())?, tol)?;
7849 let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
7850 let (t0, t1) = if reversed {
7851 (range.1, range.0)
7852 } else {
7853 (range.0, range.1)
7854 };
7855 let sign = if reversed { -1.0 } else { 1.0 };
7856 let unit = |t: f64| -> OgeomResult<Vector> {
7857 let d = placed.d1_at(t, tol)? * sign;
7858 if d.magnitude() <= tol.confusion() {
7859 ogeom_bail!(Construction, "a spine edge has no direction at {t}");
7860 }
7861 Ok(d / d.magnitude())
7862 };
7863 let station = match &placed {
7864 Curve::Line(_) => Station {
7865 from: placed.point_at(t0, tol)?,
7866 to: placed.point_at(t1, tol)?,
7867 tangent_in: unit(t0)?,
7868 tangent_out: unit(t1)?,
7869 turn: None,
7870 },
7871 Curve::Circle(c) => {
7872 let circle = c.circle();
7873 let swept = (range.1 - range.0).abs();
7874 let axis = ogeom_math::Axis {
7875 location: circle.centre(),
7876 direction: if reversed {
7877 -circle.frame().z()
7878 } else {
7879 circle.frame().z()
7880 },
7881 };
7882 Station {
7883 from: placed.point_at(t0, tol)?,
7884 to: placed.point_at(t1, tol)?,
7885 tangent_in: unit(t0)?,
7886 tangent_out: unit(t1)?,
7887 turn: Some((axis, swept)),
7888 }
7889 }
7890 other => ogeom_bail!(
7891 Construction,
7892 "a spine runs on straight and circular edges; a {:?} sweeps a \
7893 surface this construction does not have",
7894 other.kind()
7895 ),
7896 };
7897 out.push(station);
7898 }
7899 Ok(out)
7900}
7901
7902fn spine_normal(stations: &[Station], tol: Tolerances) -> OgeomResult<Direction> {
7909 let mut best: Option<(f64, Vector)> = None;
7910 let mut consider = |a: Vector, b: Vector| {
7911 let cross = a.cross(b);
7912 let magnitude = cross.magnitude();
7913 if magnitude > best.map_or(tol.angular(), |(m, _)| m) {
7914 best = Some((magnitude, cross / magnitude));
7915 }
7916 };
7917 for (index, station) in stations.iter().enumerate() {
7918 consider(station.tangent_in, station.tangent_out);
7919 if index + 1 < stations.len() {
7920 consider(station.tangent_out, stations[index + 1].tangent_in);
7921 }
7922 }
7923 if stations.len() > 1 {
7924 consider(
7925 stations[stations.len() - 1].tangent_out,
7926 stations[0].tangent_in,
7927 );
7928 }
7929 let Some((_, normal)) = best else {
7930 ogeom_bail!(
7931 Construction,
7932 "a spine that never turns has no plane of its own; give the \
7933 profile's own orientation a spine with at least one corner or arc"
7934 );
7935 };
7936 for station in stations {
7937 for tangent in [station.tangent_in, station.tangent_out] {
7938 if tangent.dot(normal).abs() > tol.angular() {
7939 ogeom_bail!(
7940 Construction,
7941 "the spine leaves its own plane; an evolved sweep runs a \
7942 planar spine"
7943 );
7944 }
7945 }
7946 if let Some((axis, _)) = &station.turn
7947 && axis.direction.vector().cross(normal).magnitude() > tol.angular()
7948 {
7949 ogeom_bail!(
7950 Construction,
7951 "a spine arc turns about an axis off the spine's own normal"
7952 );
7953 }
7954 }
7955 Direction::new(normal, tol)
7956}
7957
7958fn profile_plane(model: &Model, profile: &Shape, tol: Tolerances) -> OgeomResult<(Point, Vector)> {
7960 let mut points: Vec<Point> = Vec::new();
7961 for edge in explore(model, profile, Filter::OfType(ShapeType::Edge))? {
7962 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
7963 continue;
7964 };
7965 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
7966 continue;
7967 };
7968 let Some(geometry) = model.geometry().curve(*curve) else {
7969 ogeom_bail!(Dangling, "curve is not in this model");
7970 };
7971 let placed = geometry.transformed(&edge.transform(model.datums())?, tol)?;
7972 for i in 0..=8 {
7973 let t = range.0 + (range.1 - range.0) * f64::from(i) / 8.0;
7974 points.push(placed.point_at(t, tol)?);
7975 }
7976 }
7977 if points.len() < 3 {
7978 ogeom_bail!(Construction, "a profile needs an extent to sweep");
7979 }
7980 let origin = points[0];
7981 let mut best: Option<(f64, Vector)> = None;
7985 for (i, a) in points.iter().enumerate() {
7986 for b in points.iter().skip(i + 1) {
7987 let cross = (*a - origin).cross(*b - origin);
7988 let magnitude = cross.magnitude();
7989 if magnitude > best.map_or(tol.confusion(), |(m, _)| m) {
7990 best = Some((magnitude, cross / magnitude));
7991 }
7992 }
7993 }
7994 let Some((_, normal)) = best else {
7995 ogeom_bail!(Construction, "a profile with no area has no plane");
7996 };
7997 for p in &points {
7998 if (*p - origin).dot(normal).abs() > tol.confusion() * 1e2 {
7999 ogeom_bail!(Construction, "the profile is not planar");
8000 }
8001 }
8002 Ok((origin, normal))
8003}
8004
8005fn station_transform(
8008 reference: (Point, Vector),
8009 at: Point,
8010 tangent: Vector,
8011 normal: Direction,
8012 tol: Tolerances,
8013) -> OgeomResult<Transform> {
8014 let (origin, from) = reference;
8015 let n = normal.vector();
8016 let angle = from.cross(tangent).dot(n).atan2(from.dot(tangent));
8017 let turn = if angle.abs() <= tol.angular() {
8018 Transform::IDENTITY
8019 } else {
8020 Transform::rotation(
8021 ogeom_math::Axis {
8022 location: origin,
8023 direction: normal,
8024 },
8025 angle,
8026 )
8027 };
8028 Ok(Transform::translation(at - origin) * turn)
8029}
8030
8031#[allow(clippy::too_many_arguments)]
8037fn corner_piece(
8038 model: &mut Model,
8039 profile: &Shape,
8040 reference: (Point, Vector),
8041 corner: Point,
8042 incoming: Vector,
8043 outgoing: Vector,
8044 normal: Direction,
8045 tol: Tolerances,
8046) -> OgeomResult<Option<Shape>> {
8047 let n = normal.vector();
8048 let angle = incoming
8049 .cross(outgoing)
8050 .dot(n)
8051 .atan2(incoming.dot(outgoing));
8052 if angle.abs() <= tol.angular() {
8053 return Ok(None);
8054 }
8055 let placed = ogeom_algo::transformed(
8056 model,
8057 profile,
8058 station_transform(reference, corner, incoming, normal, tol)?,
8059 )?
8060 .shape;
8061 let axis = ogeom_math::Axis {
8062 location: corner,
8063 direction: if angle > 0.0 { normal } else { -normal },
8064 };
8065 let turned = ogeom_algo::make_revolution(model, &placed, axis, angle.abs(), tol);
8066 match turned {
8067 Ok(built) => Ok(Some(built.shape)),
8068 Err(_) => ogeom_bail!(
8069 NotDone,
8070 "the profile straddles the spine at a corner, so turning it about \
8071 that corner sweeps it through itself; there is no revolution for \
8072 that wedge"
8073 ),
8074 }
8075}
8076
8077fn fuse_in_order(
8083 model: &mut Model,
8084 pieces: Vec<Shape>,
8085 tol: Tolerances,
8086) -> OgeomResult<Option<Shape>> {
8087 let mut runs = pieces;
8088 while runs.len() > 1 {
8089 let mut next = Vec::with_capacity(runs.len().div_ceil(2));
8090 let mut pending = runs.into_iter();
8091 while let Some(first) = pending.next() {
8092 match pending.next() {
8093 Some(second) => next.push(ogeom_bool::fuse(model, &first, &second, tol)?.shape),
8094 None => next.push(first),
8095 }
8096 }
8097 runs = next;
8098 }
8099 Ok(runs.pop())
8100}