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
28mod corners;
29mod evolving;
30mod inside_turn;
31mod meeting;
32mod mitred_arcs;
33mod open_lines;
34mod open_skinned;
35mod placement;
36
37pub use evolving::{
38 PipeEvolution, PipeLaws, SpineFunction, SpineLaw, make_pipe_shell_evolving,
39 make_pipe_shell_laws,
40};
41pub use placement::{PipeContact, PipePlacement, place_on_spine};
42
43pub fn make_pipe(
65 model: &mut Model,
66 spine: &Shape,
67 radius: f64,
68 tol: Tolerances,
69) -> OgeomResult<Built> {
70 if !radius.is_finite() || radius <= tol.confusion() {
71 ogeom_bail!(Construction, "a pipe of radius {radius} holds nothing");
72 }
73 let placed = placed_path(model, spine, tol)?;
75 if placed != *spine {
76 let mut built = make_pipe(model, &placed, radius, tol)?;
77 built.history.generate(spine, built.shape.clone());
78 return Ok(built);
79 }
80 if model.kind_of(spine)? == ShapeType::Wire {
81 let edges = model.ordered_children_of(spine)?;
82 if let [edge] = edges.as_slice() {
83 let mut built = make_pipe(model, edge, radius, tol)?;
84 built.history.generate(spine, built.shape.clone());
85 return Ok(built);
86 }
87 return pipe_along_wire(model, spine, &edges, radius, tol);
88 }
89 let (curve, range) = {
90 let Some(data) = model.node(spine).and_then(|n| n.data().as_edge()) else {
91 ogeom_bail!(Construction, "a pipe runs along an edge");
92 };
93 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
94 ogeom_bail!(Construction, "the spine has no curve");
95 };
96 let Some(geometry) = model.geometry().curve(*curve) else {
97 ogeom_bail!(Dangling, "curve is not in this model");
98 };
99 (geometry.clone(), *range)
100 };
101 let mut built = match &curve {
102 Curve::Line(line) => {
103 let start = curve.point_at(range.0, tol)?;
104 let length = range.1 - range.0;
105 let frame = Frame::about(start, line.axis().direction);
106 make_cylinder(model, frame, radius, length, tol)?
107 }
108 Curve::Circle(c) => {
109 let circle = c.circle();
110 if radius >= circle.radius() - tol.confusion() {
111 ogeom_bail!(
112 Construction,
113 "a tube of radius {radius} swallows its spine of radius {}",
114 circle.radius()
115 );
116 }
117 let closed = curve
118 .point_at(range.0, tol)?
119 .distance(curve.point_at(range.1, tol)?)
120 <= tol.confusion();
121 if closed {
122 make_torus(model, circle.frame(), circle.radius(), radius, tol)?
123 } else {
124 pipe_segment(model, circle, range, radius, tol)?
125 }
126 }
127 _ => ogeom_bail!(
128 Construction,
129 "a pipe along a free-form spine needs the sweep-surface \
130 machinery; see docs/CAPABILITIES.md, offset.sweeps"
131 ),
132 };
133 built.history.generate(spine, built.shape.clone());
134 Ok(built)
135}
136
137fn pipe_along_wire(
140 model: &mut Model,
141 spine: &Shape,
142 edges: &[Shape],
143 radius: f64,
144 tol: Tolerances,
145) -> OgeomResult<Built> {
146 for edge in edges {
147 let (curve, _) = spine_curve_of(model, edge)?;
148 let basis = match &curve {
149 Curve::Trimmed(t) => t.basis().clone(),
150 other => other.clone(),
151 };
152 if !matches!(basis, Curve::Line(_) | Curve::Circle(_)) {
153 ogeom_bail!(
154 Construction,
155 "a pipe runs along lines and arcs; along a free-form spine it is \
156 make_pipe_skinned's or make_pipe_shell's, to a stated tolerance"
157 );
158 }
159 }
160 let disc = disc_at_start(model, spine, edges, radius, tol)?;
161 let built = match make_pipe_shell(model, &disc, spine, false, tol.confusion(), tol) {
165 Err(ogeom_core::OgeomError::NotDone(_)) => ogeom_bail!(
166 Construction,
167 "a pipe along this wire is not swept exactly; make_pipe_shell sweeps it \
168 to a stated tolerance"
169 ),
170 built => built?,
171 };
172 for face in explore(model, &built.shape, Filter::OfType(ShapeType::Face))? {
173 let Some(data) = model.node(&face).and_then(|n| n.data().as_face()) else {
174 continue;
175 };
176 if !matches!(
177 model.geometry().surface(data.surface),
178 Some(
179 SurfaceGeometry::Plane(_)
180 | SurfaceGeometry::Cylinder(_)
181 | SurfaceGeometry::Torus(_)
182 )
183 ) {
184 ogeom_bail!(
185 Construction,
186 "a pipe along this wire is not swept exactly; make_pipe_shell sweeps it \
187 to a stated tolerance"
188 );
189 }
190 }
191 let mut history = History::new();
192 history.generate(spine, built.shape.clone());
193 Ok(Built::new(built.shape, history))
194}
195
196fn disc_at_start(
200 model: &mut Model,
201 spine: &Shape,
202 edges: &[Shape],
203 radius: f64,
204 tol: Tolerances,
205) -> OgeomResult<Shape> {
206 for edge in edges {
207 let (curve, _) = spine_curve_of(model, edge)?;
208 let basis = match &curve {
209 Curve::Trimmed(t) => t.basis().clone(),
210 other => other.clone(),
211 };
212 if let Curve::Circle(c) = basis
213 && radius >= c.circle().radius() - tol.confusion()
214 {
215 ogeom_bail!(
216 Construction,
217 "a tube of radius {radius} swallows its spine of radius {}",
218 c.circle().radius()
219 );
220 }
221 }
222 let stations = shell_stations(model, spine, tol)?;
223 let (at, heading) = (stations[0].at, stations[0].tangent);
224 let frame = Frame::about(at, Direction::new(heading, tol)?);
225 let circle = Circle::new(frame, radius, tol)?;
226 let rim = make_edge(
227 model,
228 Curve::Circle(CircleCurve::new(circle)),
229 (0.0, core::f64::consts::TAU),
230 tol,
231 )?
232 .shape;
233 let ring = ogeom_algo::make_wire(model, &[rim], tol)?.shape;
234 Ok(ogeom_algo::make_face(
235 model,
236 PlaneSurface::new(Plane::new(frame)).into(),
237 &[ring],
238 tol,
239 )?
240 .shape)
241}
242
243fn pipe_segment(
252 model: &mut Model,
253 spine: Circle,
254 range: (f64, f64),
255 radius: f64,
256 tol: Tolerances,
257) -> OgeomResult<Built> {
258 let frame = spine.frame();
259 let (x, y, z) = (frame.x().vector(), frame.y().vector(), frame.z().vector());
260 let major = spine.radius();
261 let radial = |u: f64| x * u.cos() + y * u.sin();
262 let tangent = |u: f64| x * -u.sin() + y * u.cos();
263 let tube_point = |u: f64, v: f64| {
264 frame.origin() + radial(u) * radius.mul_add(v.cos(), major) + z * (radius * v.sin())
265 };
266 let pi = core::f64::consts::PI;
267 let tau = core::f64::consts::TAU;
268
269 let torus: SurfaceGeometry = TorusSurface::new(Torus::new(frame, major, radius, tol)?).into();
270 let surface_id = model.geometry_mut().add_surface(torus);
271
272 let ends = [range.0, range.1];
274 let mut verts: Vec<Vec<Shape>> = Vec::new();
275 for &u in &ends {
276 verts.push(vec![
277 make_vertex(model, tube_point(u, 0.0)).shape,
278 make_vertex(model, tube_point(u, pi)).shape,
279 ]);
280 }
281
282 let mut tube_arcs: Vec<Vec<Shape>> = Vec::new();
286 for (k, &u) in ends.iter().enumerate() {
287 let centre = frame.origin() + radial(u) * major;
288 let circle = Circle::new(
289 Frame::new(
290 centre,
291 Direction::new(-tangent(u), tol)?,
292 Direction::new(radial(u), tol)?,
293 tol,
294 )?,
295 radius,
296 tol,
297 )?;
298 let curve = Curve::Circle(CircleCurve::new(circle));
299 let arcs = vec![
300 make_edge_between(
301 model,
302 curve.clone(),
303 (0.0, pi),
304 &verts[k][0],
305 &verts[k][1],
306 tol,
307 )?
308 .shape,
309 make_edge_between(model, curve, (pi, tau), &verts[k][1], &verts[k][0], tol)?.shape,
310 ];
311 let column = Line2d::over(
313 ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
314 0.0,
315 tau,
316 )?;
317 ogeom_algo::attach_pcurve(
318 model,
319 &arcs[0],
320 column.into(),
321 surface_id,
322 ogeom_topo::Location::identity(),
323 (0.0, pi),
324 )?;
325 ogeom_algo::attach_pcurve(
326 model,
327 &arcs[1],
328 column.into(),
329 surface_id,
330 ogeom_topo::Location::identity(),
331 (pi, tau),
332 )?;
333 tube_arcs.push(arcs);
334 }
335
336 let parallel = |model: &mut Model, v: f64, from: &Shape, to: &Shape| -> OgeomResult<Shape> {
339 let height = radius * v.sin();
340 let ring = radius.mul_add(v.cos(), major);
341 let circle = Circle::new(
342 Frame::new(frame.origin() + z * height, frame.z(), frame.x(), tol)?,
343 ring,
344 tol,
345 )?;
346 let curve = Curve::Circle(CircleCurve::new(circle));
347 Ok(make_edge_between(model, curve, range, from, to, tol)?.shape)
348 };
349 let row = |v: f64| -> OgeomResult<Line2d> {
350 Line2d::over(
351 ogeom_math::Axis2::new(Point2::new(0.0, v), ogeom_math::Direction2::X),
352 range.0 - 1.0,
353 range.1 + 1.0,
354 )
355 };
356 let inner = parallel(model, pi, &verts[0][1], &verts[1][1])?;
357 ogeom_algo::attach_pcurve(
358 model,
359 &inner,
360 row(pi)?.into(),
361 surface_id,
362 ogeom_topo::Location::identity(),
363 range,
364 )?;
365 let outer = parallel(model, 0.0, &verts[0][0], &verts[1][0])?;
369 ogeom_algo::attach_seam(
370 model,
371 &outer,
372 row(0.0)?.into(),
373 row(tau)?.into(),
374 surface_id,
375 ogeom_topo::Location::identity(),
376 range,
377 )?;
378
379 let lower = {
383 let wire = ogeom_algo::make_wire(
384 model,
385 &[
386 outer.clone(),
387 tube_arcs[1][0].clone(),
388 inner.reversed(),
389 tube_arcs[0][0].reversed(),
390 ],
391 tol,
392 )?
393 .shape;
394 ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape
395 };
396 let upper = {
397 let wire = ogeom_algo::make_wire(
398 model,
399 &[
400 inner.clone(),
401 tube_arcs[1][1].clone(),
402 outer.reversed(),
403 tube_arcs[0][1].reversed(),
404 ],
405 tol,
406 )?
407 .shape;
408 ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape
409 };
410
411 let mut caps: Vec<Shape> = Vec::new();
414 for (k, &u) in ends.iter().enumerate() {
415 let outward = if k == 0 { -tangent(u) } else { tangent(u) };
416 let centre = frame.origin() + radial(u) * major;
417 let plane = Plane::through(centre, Direction::new(outward, tol)?);
418 let reach = (major + radius) * 2.0;
419 let surface: SurfaceGeometry =
420 PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
421 let ring = if k == 0 {
424 vec![tube_arcs[k][0].clone(), tube_arcs[k][1].clone()]
425 } else {
426 vec![tube_arcs[k][1].reversed(), tube_arcs[k][0].reversed()]
427 };
428 caps.push(make_face_with_pcurves(model, surface, &[ring], tol)?.shape);
429 }
430
431 let faces = [lower, upper, caps[0].clone(), caps[1].clone()];
432 let sewn = sew(model, &faces, tol)?;
433 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
434 ogeom_bail!(Construction, "the pipe segment did not close");
435 }
436 make_solid(model, std::slice::from_ref(&sewn.shells[0]))
437}
438
439pub fn make_loft(
455 model: &mut Model,
456 bottom: &Shape,
457 top: &Shape,
458 tol: Tolerances,
459) -> OgeomResult<Built> {
460 match (model.kind_of(bottom)?, model.kind_of(top)?) {
463 (ShapeType::Wire, ShapeType::Vertex) => {
464 return loft_to_point(model, bottom, top, tol);
465 }
466 (ShapeType::Vertex, ShapeType::Wire) => {
467 let mut built = loft_to_point(model, top, bottom, tol)?;
468 built.history.generate(bottom, built.shape.clone());
470 return Ok(built);
471 }
472 _ => {}
473 }
474 for wire in [bottom, top] {
475 if model.kind_of(wire)? != ShapeType::Wire {
476 ogeom_bail!(Construction, "a loft runs between wires");
477 }
478 if !ogeom_algo::is_wire_closed(model, wire, tol)? {
479 ogeom_bail!(Construction, "a loft section must be closed");
480 }
481 }
482 let circle_of = |model: &Model, wire: &Shape| -> OgeomResult<Option<Circle>> {
483 let edges = explore(model, wire, Filter::OfType(ShapeType::Edge))?;
484 if edges.len() != 1 {
485 return Ok(None);
486 }
487 let Some(data) = model.node(&edges[0]).and_then(|n| n.data().as_edge()) else {
488 return Ok(None);
489 };
490 let Some(EdgeRepr::Curve3d { curve, .. }) = data.curve3d() else {
491 return Ok(None);
492 };
493 match model.geometry().curve(*curve) {
494 Some(Curve::Circle(c)) => Ok(Some(c.circle())),
495 _ => Ok(None),
496 }
497 };
498
499 if let (Some(lower), Some(upper)) = (circle_of(model, bottom)?, circle_of(model, top)?) {
500 let axis = lower.frame().z().vector();
502 let rise = upper.centre() - lower.centre();
503 let height = rise.dot(axis);
504 if rise.cross(axis).magnitude() > tol.confusion() * 10.0 || height.abs() <= tol.confusion()
505 {
506 ogeom_bail!(
507 Construction,
508 "lofted circles must be coaxial on parallel planes; the \
509 oblique loft needs the sweep machinery; see the deferred \
510 table"
511 );
512 }
513 let frame = if height > 0.0 {
514 Frame::new(lower.centre(), lower.frame().z(), lower.frame().x(), tol)?
515 } else {
516 Frame::new(
517 lower.centre(),
518 lower.frame().z().reversed(),
519 lower.frame().x(),
520 tol,
521 )?
522 };
523 let mut built = if (lower.radius() - upper.radius()).abs() <= tol.confusion() {
524 make_cylinder(model, frame, lower.radius(), height.abs(), tol)?
525 } else {
526 make_cone(
527 model,
528 frame,
529 lower.radius(),
530 upper.radius(),
531 height.abs(),
532 tol,
533 )?
534 };
535 built.history.generate(bottom, built.shape.clone());
536 built.history.generate(top, built.shape.clone());
537 return Ok(built);
538 }
539
540 let corners_of = |model: &Model, wire: &Shape| -> OgeomResult<Vec<Point>> {
542 let mut out = Vec::new();
543 for edge in explore(model, wire, Filter::OfType(ShapeType::Edge))? {
544 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
545 ogeom_bail!(Construction, "a section edge holds no data");
546 };
547 let Some(EdgeRepr::Curve3d { curve, .. }) = data.curve3d() else {
548 ogeom_bail!(Construction, "a section edge has no curve");
549 };
550 let Some(Curve::Line(_)) = model.geometry().curve(*curve) else {
551 ogeom_bail!(
552 Construction,
553 "a mixed or curved section needs the skinning machinery; see \
554 docs/CAPABILITIES.md, offset.sweeps"
555 );
556 };
557 let Some((sv, _)) = edge_vertices(model, &edge)? else {
558 ogeom_bail!(Construction, "a section edge has no vertices");
559 };
560 let Some(data) = model.node(&sv).and_then(|n| n.data().as_vertex()) else {
561 ogeom_bail!(Construction, "a section vertex holds no point");
562 };
563 out.push(sv.transform(model.datums())?.apply(data.point));
564 }
565 Ok(out)
566 };
567 let low = corners_of(model, bottom)?;
568 let high = corners_of(model, top)?;
569 if low.len() != high.len() {
570 ogeom_bail!(
571 Construction,
572 "lofted sections must have the same corner count, found {} and {}",
573 low.len(),
574 high.len()
575 );
576 }
577 let n = low.len();
578 let centroid = {
579 let mut c = Vector::new(0.0, 0.0, 0.0);
580 for p in low.iter().chain(high.iter()) {
581 c += p.to_vector();
582 }
583 #[allow(clippy::cast_precision_loss)]
584 let count = 2.0 * n as f64;
585 Point::from_vector(c / count)
586 };
587
588 let vl: Vec<Shape> = low.iter().map(|p| make_vertex(model, *p).shape).collect();
590 let vh: Vec<Shape> = high.iter().map(|p| make_vertex(model, *p).shape).collect();
591 let seg = |model: &mut Model, a: (&Shape, Point), b: (&Shape, Point)| -> OgeomResult<Shape> {
592 let line = LineCurve::segment(a.1, b.1, tol)?;
593 let curve = Curve::Line(line);
594 let domain = curve.domain();
595 Ok(make_edge_between(model, curve, domain, a.0, b.0, tol)?.shape)
596 };
597 let mut low_edges = Vec::with_capacity(n);
598 let mut high_edges = Vec::with_capacity(n);
599 let mut rails = Vec::with_capacity(n);
600 for i in 0..n {
601 let j = (i + 1) % n;
602 low_edges.push(seg(model, (&vl[i], low[i]), (&vl[j], low[j]))?);
603 high_edges.push(seg(model, (&vh[i], high[i]), (&vh[j], high[j]))?);
604 rails.push(seg(model, (&vl[i], low[i]), (&vh[i], high[i]))?);
605 }
606
607 let planar = |model: &mut Model, corners: &[Point], edges: Vec<Shape>| -> OgeomResult<Shape> {
608 let normal = {
609 let mut n = (corners[1] - corners[0]).cross(corners[2] - corners[0]);
610 let m = n.magnitude();
611 if m <= tol.confusion() {
612 ogeom_bail!(Construction, "a loft wall is degenerate");
613 }
614 n /= m;
615 if n.dot(corners[0] - centroid) < 0.0 {
616 -n
617 } else {
618 n
619 }
620 };
621 let skew = corners.iter().any(|p| {
622 Plane::through(
623 corners[0],
624 Direction::new(normal, tol).unwrap_or(Direction::Z),
625 )
626 .distance_to(*p)
627 > tol.confusion() * 10.0
628 });
629 if skew {
630 if corners.len() != 4 || edges.len() != 4 {
634 ogeom_bail!(Construction, "a skew ruled wall has four corners");
635 }
636 return bilinear_wall(model, corners, &edges, centroid, tol);
637 }
638 let plane = Plane::through(corners[0], Direction::new(normal, tol)?);
639 let mut reach = 1.0_f64;
640 for p in corners {
641 reach = reach.max(p.distance(corners[0]) * 2.0);
642 }
643 let surface: SurfaceGeometry =
644 PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
645 let turn = (0..corners.len())
648 .map(|i| {
649 corners[i]
650 .to_vector()
651 .cross(corners[(i + 1) % corners.len()].to_vector())
652 })
653 .fold(Vector::new(0.0, 0.0, 0.0), |sum, v| sum + v);
654 let ring = if turn.dot(normal) < 0.0 {
655 walked_back(&edges)
656 } else {
657 edges
658 };
659 Ok(make_face_with_pcurves(model, surface, &[ring], tol)?.shape)
660 };
661
662 let mut faces: Vec<Shape> = Vec::with_capacity(n + 2);
663 for i in 0..n {
664 let j = (i + 1) % n;
665 faces.push(planar(
666 model,
667 &[low[i], low[j], high[j], high[i]],
668 vec![
669 low_edges[i].clone(),
670 rails[j].clone(),
671 high_edges[i].reversed(),
672 rails[i].reversed(),
673 ],
674 )?);
675 }
676 faces.push(planar(model, &low, low_edges.clone())?);
677 faces.push(planar(model, &high, high_edges.clone())?);
678
679 let sewn = sew(model, &faces, tol)?;
680 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
681 ogeom_bail!(Construction, "the loft did not close");
682 }
683 let mut built = make_solid(model, std::slice::from_ref(&sewn.shells[0]))?;
684 built.history.generate(bottom, built.shape.clone());
685 built.history.generate(top, built.shape.clone());
686 Ok(built)
687}
688
689fn bilinear_wall(
699 model: &mut Model,
700 corners: &[Point],
701 edges: &[Shape],
702 centroid: Point,
703 tol: Tolerances,
704) -> OgeomResult<Shape> {
705 use ogeom_geom::Surface as _;
706 let (p00, p10, p11, p01) = (corners[0], corners[1], corners[2], corners[3]);
707 let grid = ogeom_math::ControlGrid::new(vec![p00, p01, p10, p11], 2, 2)?;
708 let line = ogeom_math::KnotVector::clamped_uniform(1, 2)?;
709 let patch = ogeom_geom::BSplineSurface::new(line.clone(), line, &grid, tol)?;
710 let outward = {
711 let (du, dv) = patch.d1_at(0.5, 0.5, tol)?;
712 let centre = patch.point_at(0.5, 0.5, tol)?;
713 du.cross(dv).dot(centre - centroid) >= 0.0
714 };
715 let surface_id = model
716 .geometry_mut()
717 .add_surface(SurfaceGeometry::BSpline(patch));
718
719 let sides: [(Point2, Point2); 4] = [
724 (Point2::new(0.0, 0.0), Point2::new(1.0, 0.0)),
725 (Point2::new(1.0, 0.0), Point2::new(1.0, 1.0)),
726 (Point2::new(0.0, 1.0), Point2::new(1.0, 1.0)),
727 (Point2::new(0.0, 0.0), Point2::new(0.0, 1.0)),
728 ];
729 for (edge, (from, to)) in edges.iter().zip(sides) {
730 let range = {
731 let Some(node) = model.node(edge) else {
732 ogeom_bail!(Dangling, "a loft edge is not in this model");
733 };
734 let Some(data) = node.data().as_edge() else {
735 ogeom_bail!(Construction, "a loft edge holds no edge data");
736 };
737 let Some(EdgeRepr::Curve3d { range, .. }) = data.curve3d() else {
738 ogeom_bail!(Construction, "a loft edge has no curve");
739 };
740 *range
741 };
742 let knots = ogeom_math::KnotVector::new(vec![range.0, range.0, range.1, range.1], 1)?;
743 let pcurve = ogeom_geom::BSpline2d::new(knots, vec![from, to], tol)?;
744 ogeom_algo::attach_pcurve(
745 model,
746 edge,
747 pcurve.into(),
748 surface_id,
749 ogeom_topo::Location::identity(),
750 range,
751 )?;
752 }
753 let wire = ogeom_algo::make_wire(model, edges, tol)?.shape;
754 let face = ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
755 Ok(if outward { face } else { face.reversed() })
756}
757
758struct Skin<'a> {
761 rows: Vec<Vec<Point>>,
764 round: bool,
766 by_spacing: bool,
769 traced: Option<Traced<'a>>,
774 carried: Option<Carried<'a>>,
777}
778
779struct Carried<'a> {
789 curve: ogeom_geom::BSplineCurve,
791 control: Box<dyn Fn(usize, f64) -> OgeomResult<Point> + 'a>,
793 vs: Vec<f64>,
796 closed: bool,
799}
800
801impl Carried<'_> {
802 fn fit(
805 &self,
806 tolerance: f64,
807 tol: Tolerances,
808 ) -> OgeomResult<ogeom_geom::fit::Fitted<ogeom_geom::BSplineSurface>> {
809 let control = self.curve.control_points();
810 let n = control.len();
811 let mut paths: Vec<ogeom_geom::BSplineCurve> = Vec::with_capacity(n);
812 let mut error = 0.0_f64;
813 for i in 0..n {
814 if self.closed && i + 1 == n {
815 paths.push(paths[0].clone());
816 continue;
817 }
818 let fitted = ogeom_geom::fit::fit_curve_sampled(
819 |v| (self.control)(i, v),
820 &self.vs,
821 false,
822 3,
823 tolerance,
824 tol,
825 )?;
826 error = error.max(fitted.error);
827 paths.push(fitted.curve);
828 }
829 let paths = crate::sheet::made_compatible(&paths, tol)?;
830 let (v0, v1) = (self.vs[0], self.vs[self.vs.len() - 1]);
831 let mut net = Vec::with_capacity(n * paths[0].control_points().len());
832 for (i, path) in paths.iter().enumerate() {
833 let points: Vec<Point> = path.control_points().iter().map(|w| w.point()).collect();
834 let last = points.len() - 1;
835 let i = if self.closed && i + 1 == n { 0 } else { i };
838 let ends = ((self.control)(i, v0)?, (self.control)(i, v1)?);
839 error = error
840 .max(points[0].distance(ends.0))
841 .max(points[last].distance(ends.1));
842 let weight = control[i].weight;
843 for (j, p) in points.iter().enumerate() {
844 let p = match j {
845 0 => ends.0,
846 j if j == last => ends.1,
847 _ => *p,
848 };
849 net.push(ogeom_math::Weighted::new(p, weight, tol)?);
850 }
851 }
852 let grid = ogeom_math::ControlGrid::new(net, n, paths[0].control_points().len())?;
853 let surface = ogeom_geom::BSplineSurface::rational(
854 self.curve.knots().clone(),
855 paths[0].knots().clone(),
856 grid,
857 )?;
858 Ok(ogeom_geom::fit::Fitted {
859 curve: surface,
860 error,
861 met: error <= tolerance,
862 })
863 }
864}
865
866struct Traced<'a> {
868 point: Box<dyn Fn(f64, f64) -> OgeomResult<Point> + 'a>,
869 sampling: ogeom_geom::fit::Sampling,
870}
871
872#[derive(Clone)]
875enum Section {
876 Loop(ArcLoop),
877 Point(Point),
878 Halfway(ArcLoop, Point),
880}
881
882impl Section {
883 fn at(&self, f: f64) -> Point {
885 match self {
886 Self::Loop(arc) => arc.at(f),
887 Self::Point(p) => *p,
888 Self::Halfway(arc, to) => {
889 Point::from_vector((arc.at(f).to_vector() + to.to_vector()) * 0.5)
890 }
891 }
892 }
893}
894
895const AROUND_SECTION: usize = 48;
898
899fn fractions(n: usize) -> Vec<f64> {
901 (0..=n)
902 .map(|i| {
903 #[allow(clippy::cast_precision_loss, reason = "a small count")]
904 let f = i as f64 / n as f64;
905 f
906 })
907 .collect()
908}
909
910impl<'a> Skin<'a> {
911 fn rows(rows: Vec<Vec<Point>>) -> Self {
914 Self {
915 rows,
916 round: true,
917 by_spacing: false,
918 traced: None,
919 carried: None,
920 }
921 }
922
923 fn columns(
932 column: impl Fn(f64) -> OgeomResult<Vec<Point>> + 'a,
933 us: Vec<f64>,
934 round: bool,
935 by_spacing: bool,
936 closed_v: bool,
937 ) -> OgeomResult<Self> {
938 let sampled: Vec<Vec<Point>> = us[..us.len() - usize::from(round)]
939 .iter()
940 .map(|u| column(*u))
941 .collect::<OgeomResult<_>>()?;
942 let count = sampled[0].len();
943 let rows: Vec<Vec<Point>> = (0..count)
944 .map(|j| sampled.iter().map(|c| c[j]).collect())
945 .collect();
946 let mut grid: Vec<Vec<Point>> = rows
947 .iter()
948 .map(|r| {
949 let mut r = r.clone();
950 if round {
951 r.push(r[0]);
952 }
953 r
954 })
955 .collect();
956 if closed_v {
957 grid.push(grid[0].clone());
958 }
959 let bare = Self {
960 rows: rows.clone(),
961 round,
962 by_spacing,
963 traced: None,
964 carried: None,
965 };
966 let (_, vs) = ogeom_geom::fit::grid_parameters(&grid, (false, by_spacing))?;
967 if vs.windows(2).any(|w| w[1] <= w[0]) {
968 return Ok(bare);
971 }
972 let row_vs = vs.clone();
973 let (u0, u1) = (us[0], us[us.len() - 1]);
974 let held: std::cell::RefCell<ogeom_core::FastMap<u64, Vec<Point>>> =
975 std::cell::RefCell::default();
976 let point = move |u: f64, v: f64| -> OgeomResult<Point> {
977 let Some(j) = row_vs.iter().position(|w| w.to_bits() == v.to_bits()) else {
978 ogeom_bail!(Construction, "a skin is known across only at its rows");
979 };
980 let u = if round && u >= u1 { u0 } else { u };
983 if let Some(c) = held.borrow().get(&u.to_bits()) {
984 return Ok(c[j % count]);
985 }
986 let c = column(u)?;
987 if c.len() != count {
988 ogeom_bail!(Construction, "a skin's column changed its length");
989 }
990 let p = c[j % count];
991 held.borrow_mut().insert(u.to_bits(), c);
992 Ok(p)
993 };
994 Ok(Self {
995 traced: Some(Traced {
996 point: Box::new(point),
997 sampling: ogeom_geom::fit::Sampling {
998 us,
999 vs,
1000 between: (true, false),
1001 closed_v,
1002 most: 512,
1003 },
1004 }),
1005 ..bare
1006 })
1007 }
1008
1009 fn swept(
1017 point: impl Fn(f64, f64) -> OgeomResult<Point> + 'a,
1018 us: Vec<f64>,
1019 stations: (usize, usize),
1020 round: bool,
1021 ) -> OgeomResult<Self> {
1022 let (u0, u1) = (us[0], us[us.len() - 1]);
1023 #[allow(clippy::cast_precision_loss, reason = "station counts")]
1024 let (first, steps) = (stations.0 as f64, (stations.1 - stations.0) as f64);
1025 if steps < 1.0 {
1026 ogeom_bail!(Construction, "a swept skin needs two stations");
1027 }
1028 let vs: Vec<f64> = (stations.0..=stations.1)
1029 .map(|i| {
1030 #[allow(clippy::cast_precision_loss, reason = "station counts")]
1031 let step = (i - stations.0) as f64;
1032 step / steps
1033 })
1034 .collect();
1035 let point = move |u: f64, v: f64| -> OgeomResult<Point> {
1038 let s = first + v * steps;
1039 let whole = s.round();
1040 let s = if (s - whole).abs() <= 1e-9 { whole } else { s };
1041 point(if round && u >= u1 { u0 } else { u }, s)
1042 };
1043 let rows: Vec<Vec<Point>> = vs
1044 .iter()
1045 .map(|v| {
1046 us[..us.len() - usize::from(round)]
1047 .iter()
1048 .map(|u| point(*u, *v))
1049 .collect::<OgeomResult<Vec<Point>>>()
1050 })
1051 .collect::<OgeomResult<_>>()?;
1052 Ok(Self {
1053 rows,
1054 round,
1055 by_spacing: false,
1056 traced: Some(Traced {
1057 point: Box::new(point),
1058 sampling: ogeom_geom::fit::Sampling {
1059 us,
1060 vs,
1061 between: (true, true),
1062 closed_v: false,
1063 most: 512,
1064 },
1065 }),
1066 carried: None,
1067 })
1068 }
1069
1070 fn sections(sections: Vec<Section>, closed: bool) -> OgeomResult<Self> {
1073 Self::columns(
1074 move |u| Ok(sections.iter().map(|s| s.at(u)).collect()),
1075 fractions(AROUND_SECTION),
1076 true,
1077 false,
1078 closed,
1079 )
1080 }
1081
1082 fn row_parameters(&self) -> OgeomResult<Vec<f64>> {
1085 if let Some(traced) = &self.traced {
1086 return Ok(traced.sampling.vs.clone());
1087 }
1088 let grid: Vec<Vec<Point>> = self
1089 .rows
1090 .iter()
1091 .map(|row| {
1092 let mut r = row.clone();
1093 if self.round {
1094 r.push(row[0]);
1095 }
1096 r
1097 })
1098 .collect();
1099 Ok(ogeom_geom::fit::grid_parameters(&grid, (false, self.by_spacing))?.1)
1100 }
1101
1102 fn fit(
1107 &self,
1108 closed_v: bool,
1109 tolerance: f64,
1110 tol: Tolerances,
1111 ) -> OgeomResult<ogeom_geom::fit::Fitted<ogeom_geom::BSplineSurface>> {
1112 if let Some(carried) = &self.carried
1115 && !closed_v
1116 && let Ok(fitted) = carried.fit(tolerance, tol)
1117 && fitted.met
1118 {
1119 return Ok(fitted);
1120 }
1121 match &self.traced {
1122 None => {
1123 let mut grid: Vec<Vec<Point>> = self
1124 .rows
1125 .iter()
1126 .map(|row| {
1127 let mut r = row.clone();
1128 if self.round {
1129 r.push(row[0]);
1130 }
1131 r
1132 })
1133 .collect();
1134 if closed_v {
1135 grid.push(grid[0].clone());
1136 ogeom_geom::fit::fit_surface_grid_closed_v(&grid, 3, tolerance, tol)
1137 } else if self.by_spacing {
1138 ogeom_geom::fit::fit_surface_grid_sections(&grid, 3, tolerance, tol)
1139 } else {
1140 ogeom_geom::fit::fit_surface_grid(&grid, 3, tolerance, tol)
1141 }
1142 }
1143 Some(traced) => {
1144 let mut sampling = traced.sampling.clone();
1145 sampling.closed_v = closed_v;
1146 const TIGHTER: usize = 3;
1150 let mut target = tolerance;
1151 let mut best: Option<ogeom_geom::fit::Fitted<ogeom_geom::BSplineSurface>> = None;
1152 for round in 0..=TIGHTER {
1153 let fit = ogeom_geom::fit::fit_surface_sampled(
1154 |u, v| (traced.point)(u, v),
1155 &sampling,
1156 3,
1157 target,
1158 tol,
1159 );
1160 let mut fitted = match fit {
1163 Ok(fitted) => fitted,
1164 Err(e) if round == 0 => return Err(e),
1165 Err(_) => break,
1166 };
1167 fitted.met = fitted.error <= tolerance;
1168 let strays = !fitted.met;
1169 let error = fitted.error;
1170 if best.as_ref().is_none_or(|b| error < b.error) {
1171 best = Some(fitted);
1172 }
1173 if !strays {
1174 break;
1175 }
1176 target *= 0.9 * tolerance / error;
1177 }
1178 best.ok_or_else(|| {
1179 ogeom_core::ogeom_err!(Construction, "the skin could not be fitted")
1180 })
1181 }
1182 }
1183 }
1184}
1185
1186#[derive(Clone)]
1188struct ArcLoop {
1189 dense: Vec<Point>,
1190 lengths: Vec<f64>,
1192 total: f64,
1194}
1195
1196impl ArcLoop {
1197 fn new(dense: Vec<Point>) -> Self {
1198 let mut lengths = Vec::with_capacity(dense.len());
1199 lengths.push(0.0);
1200 for w in dense.windows(2) {
1201 let last = lengths[lengths.len() - 1];
1202 lengths.push(last + w[0].distance(w[1]));
1203 }
1204 let total = lengths[lengths.len() - 1] + dense[dense.len() - 1].distance(dense[0]);
1205 Self {
1206 dense,
1207 lengths,
1208 total,
1209 }
1210 }
1211
1212 fn at(&self, f: f64) -> Point {
1215 let f = if (0.0..1.0).contains(&f) { f } else { 0.0 };
1216 let target = self.total * f;
1217 let n = self.dense.len();
1218 let cursor = self.lengths[1..]
1219 .partition_point(|l| *l < target)
1220 .min(n - 1);
1221 let (a, b) = (self.dense[cursor], self.dense[(cursor + 1) % n]);
1222 let la = self.lengths[cursor];
1223 let lb = if cursor + 1 < n {
1224 self.lengths[cursor + 1]
1225 } else {
1226 self.total
1227 };
1228 let t = if lb > la {
1229 (target - la) / (lb - la)
1230 } else {
1231 0.0
1232 };
1233 a + (b - a) * t.clamp(0.0, 1.0)
1234 }
1235
1236 fn winding(&self) -> Vector {
1239 let o = self.dense[0];
1240 let mut sum = Vector::new(0.0, 0.0, 0.0);
1241 for w in self.dense[1..].windows(2) {
1242 sum += (w[0] - o).cross(w[1] - o);
1243 }
1244 sum * 0.5
1245 }
1246
1247 fn reversed_against(&self, with: &Self) -> Self {
1251 let mut back = self.dense.clone();
1252 back[1..].reverse();
1253 let marks = fractions(AROUND_SECTION);
1254 let theirs: Vec<Point> = marks.iter().map(|&f| with.at(f)).collect();
1255 let starts = back.len();
1256 let step = (starts / (4 * AROUND_SECTION)).max(1);
1257 let mut best = (f64::INFINITY, 0);
1258 for start in (0..starts).step_by(step) {
1259 let mut turned = back.clone();
1260 turned.rotate_left(start);
1261 let turned = Self::new(turned);
1262 let cost: f64 = marks
1263 .iter()
1264 .zip(&theirs)
1265 .map(|(&f, p)| {
1266 let d = turned.at(f) - *p;
1267 d.dot(d)
1268 })
1269 .sum();
1270 if cost < best.0 {
1271 best = (cost, start);
1272 }
1273 }
1274 back.rotate_left(best.1);
1275 Self::new(back)
1276 }
1277}
1278
1279fn wound_alike(loops: &mut [Section]) {
1290 for i in 1..loops.len() {
1291 let (before, after) = loops.split_at_mut(i);
1292 let (Section::Loop(prev), Section::Loop(this)) = (&before[i - 1], &mut after[0]) else {
1293 continue;
1294 };
1295 let (a, b) = (prev.winding(), this.winding());
1296 if a.dot(b) < -1e-6 * a.magnitude() * b.magnitude() {
1297 *this = this.reversed_against(prev);
1298 }
1299 }
1300}
1301
1302struct SkinnedWall {
1306 face: Shape,
1307 ring0: Shape,
1308 ring1: Shape,
1309 curve0: ogeom_geom::Curve,
1310 curve1: ogeom_geom::Curve,
1311 u_dom: (f64, f64),
1312}
1313
1314fn skinned_wall(
1324 model: &mut Model,
1325 skin: &Skin<'_>,
1326 shared: (Option<&Shape>, Option<&Shape>),
1327 tolerance: f64,
1328 tol: Tolerances,
1329) -> OgeomResult<SkinnedWall> {
1330 use ogeom_geom::Surface as _;
1331 let rows = &skin.rows;
1332 let fitted = skin.fit(false, tolerance, tol)?;
1333 if !fitted.met {
1334 ogeom_bail!(
1335 NotDone,
1336 "the skin reached {} against a target of {tolerance}",
1337 fitted.error
1338 );
1339 }
1340 let surface = fitted.curve;
1341 let (u_knots, v_knots) = (surface.u_knots().clone(), surface.v_knots().clone());
1342 let (k, l, net) = {
1343 let grid = surface.grid();
1344 (grid.u_count(), grid.v_count(), grid.points().to_vec())
1345 };
1346 let point_at = |i: usize, j: usize| net[i * l + j];
1347 let (u_dom, v_dom) = surface.domain();
1348
1349 let border_v = |j: usize| -> OgeomResult<ogeom_geom::Curve> {
1352 let control: Vec<_> = (0..k).map(|i| point_at(i, j)).collect();
1353 Ok(ogeom_geom::Curve::BSpline(
1354 ogeom_geom::BSplineCurve::rational(u_knots.clone(), control)?,
1355 ))
1356 };
1357 let seam_curve = {
1358 let control: Vec<_> = (0..l).map(|j| point_at(0, j)).collect();
1359 ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::rational(
1360 v_knots.clone(),
1361 control,
1362 )?)
1363 };
1364
1365 let surface_geo: SurfaceGeometry = surface.into();
1366 let surface_id = model.geometry_mut().add_surface(surface_geo.clone());
1367
1368 let slack = fitted.error + tol.confusion();
1371 let ring_of = |model: &mut Model,
1372 j: usize,
1373 given: Option<&Shape>|
1374 -> OgeomResult<(Shape, ogeom_geom::Curve)> {
1375 match given {
1376 Some(edge) => {
1377 adopt_border(model, edge, &surface_geo, slack, tol)?;
1378 Ok((edge.clone(), spine_curve_of(model, edge)?.0))
1379 }
1380 None => {
1381 let curve = border_v(j)?;
1382 Ok((make_edge(model, curve.clone(), u_dom, tol)?.shape, curve))
1383 }
1384 }
1385 };
1386 let (ring0, curve0) = ring_of(model, 0, shared.0)?;
1387 let (ring1, curve1) = ring_of(model, l - 1, shared.1)?;
1388 let anchor0 = ogeom_algo::edge_vertices(model, &ring0)?
1389 .map(|(a, _)| a)
1390 .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "a skinned ring has no vertex"))?;
1391 let anchor1 = ogeom_algo::edge_vertices(model, &ring1)?
1392 .map(|(a, _)| a)
1393 .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "a skinned ring has no vertex"))?;
1394 let seam = make_edge_between(model, seam_curve, v_dom, &anchor0, &anchor1, tol)?.shape;
1395
1396 let row_line = |v: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1398 Ok(Line2d::over(
1399 ogeom_math::Axis2::new(Point2::new(0.0, v), ogeom_math::Direction2::X),
1400 u_dom.0 - 1.0,
1401 u_dom.1 + 1.0,
1402 )?
1403 .into())
1404 };
1405 let column_line = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1406 Ok(Line2d::over(
1407 ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
1408 v_dom.0 - 1.0,
1409 v_dom.1 + 1.0,
1410 )?
1411 .into())
1412 };
1413 ogeom_algo::attach_pcurve(
1414 model,
1415 &ring0,
1416 row_line(v_dom.0)?,
1417 surface_id,
1418 ogeom_topo::Location::identity(),
1419 u_dom,
1420 )?;
1421 ogeom_algo::attach_pcurve(
1422 model,
1423 &ring1,
1424 row_line(v_dom.1)?,
1425 surface_id,
1426 ogeom_topo::Location::identity(),
1427 u_dom,
1428 )?;
1429 ogeom_algo::attach_seam(
1430 model,
1431 &seam,
1432 column_line(u_dom.0)?,
1433 column_line(u_dom.1)?,
1434 surface_id,
1435 ogeom_topo::Location::identity(),
1436 v_dom,
1437 )?;
1438
1439 let wall = {
1440 let wire = ogeom_algo::make_wire(
1441 model,
1442 &[
1443 ring0.clone(),
1444 seam.clone(),
1445 ring1.reversed(),
1446 seam.reversed(),
1447 ],
1448 tol,
1449 )?
1450 .shape;
1451 let face =
1452 ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
1453 let mid_u = f64::midpoint(u_dom.0, u_dom.1);
1458 let mid_v = f64::midpoint(v_dom.0, v_dom.1);
1459 let s_mid = surface_geo.point_at(mid_u, mid_v, tol)?;
1460 let (du, dv) = surface_geo.d1_at(mid_u, mid_v, tol)?;
1461 if du.cross(dv).dot(s_mid - section_centre(rows, s_mid)) >= 0.0 {
1462 face
1463 } else {
1464 face.reversed()
1465 }
1466 };
1467 Ok(SkinnedWall {
1468 face: wall,
1469 ring0,
1470 ring1,
1471 curve0,
1472 curve1,
1473 u_dom,
1474 })
1475}
1476
1477fn section_centre(rows: &[Vec<Point>], at: Point) -> Point {
1480 let nearest = rows.iter().filter(|row| !row.is_empty()).min_by(|a, b| {
1481 let d = |row: &Vec<Point>| {
1482 row.iter()
1483 .map(|p| p.distance(at))
1484 .fold(f64::INFINITY, f64::min)
1485 };
1486 d(a).total_cmp(&d(b))
1487 });
1488 let Some(row) = nearest else {
1489 return at;
1490 };
1491 let sum = row
1492 .iter()
1493 .fold(Vector::new(0.0, 0.0, 0.0), |acc, p| acc + p.to_vector());
1494 #[allow(clippy::cast_precision_loss)]
1495 Point::from_vector(sum / row.len() as f64)
1496}
1497
1498fn skinned_ring_strip(
1504 model: &mut Model,
1505 skin: &Skin<'_>,
1506 outward_hint: Point,
1507 shared: [Option<&Shape>; 2],
1508 tolerance: f64,
1509 tol: Tolerances,
1510) -> OgeomResult<(Shape, Shape, Shape)> {
1511 use ogeom_geom::Surface as _;
1512 let fitted = skin.fit(true, tolerance, tol)?;
1514 if !fitted.met {
1515 ogeom_bail!(
1516 NotDone,
1517 "the ring strip reached {} against a target of {tolerance}",
1518 fitted.error
1519 );
1520 }
1521 let surface = fitted.curve;
1522 let (u_knots, v_knots) = (surface.u_knots().clone(), surface.v_knots().clone());
1523 let (k, l, net) = {
1524 let grid = surface.grid();
1525 let net: Vec<Point> = grid.points().iter().map(|w| (*w).point()).collect();
1526 (grid.u_count(), grid.v_count(), net)
1527 };
1528 let point_at = |i: usize, j: usize| -> Point { net[i * l + j] };
1529 let (u_dom, v_dom) = surface.domain();
1530
1531 let rail_curve = |i: usize| -> OgeomResult<ogeom_geom::Curve> {
1536 let control: Vec<Point> = (0..l).map(|j| point_at(i, j)).collect();
1537 Ok(ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
1538 v_knots.clone(),
1539 control,
1540 tol,
1541 )?))
1542 };
1543 let seam_curve = {
1544 let control: Vec<Point> = (0..k).map(|i| point_at(i, 0)).collect();
1545 ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
1546 u_knots.clone(),
1547 control,
1548 tol,
1549 )?)
1550 };
1551 let surface_geo: SurfaceGeometry = surface.into();
1552 let surface_id = model.geometry_mut().add_surface(surface_geo.clone());
1553
1554 let slack = fitted.error + tol.confusion();
1562 let rail_of = |model: &mut Model, i: usize, given: Option<&Shape>| -> OgeomResult<Shape> {
1563 let Some(edge) = given else {
1564 let edge = make_edge(model, rail_curve(i)?, v_dom, tol)?.shape;
1565 model.widen(&edge, ogeom_core::Tolerance::new(slack)?)?;
1566 return Ok(edge);
1567 };
1568 let (curve, range) = spine_curve_of(model, edge)?;
1569 let mut off: f64 = 0.0;
1570 for step in 0..=32 {
1571 #[allow(clippy::cast_precision_loss)]
1572 let t = range.0 + (range.1 - range.0) * (step as f64) / 32.0;
1573 let p = curve.point_at(t, tol)?;
1574 off = off.max(ogeom_algo::project_on_surface(&surface_geo, p, 16, tol)?.distance);
1575 }
1576 model.widen(edge, ogeom_core::Tolerance::new(off + slack)?)?;
1577 if let Some((a, b)) = ogeom_algo::edge_vertices(model, edge)? {
1578 for v in [&a, &b] {
1579 model.widen(v, ogeom_core::Tolerance::new(off + slack)?)?;
1580 }
1581 }
1582 Ok(edge.clone())
1583 };
1584 let rail0 = rail_of(model, 0, shared[0])?;
1585 let rail1 = rail_of(model, k - 1, shared[1])?;
1586 let anchor0 = ogeom_algo::edge_vertices(model, &rail0)?
1587 .map(|(a, _)| a)
1588 .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "a strip rail has no vertex"))?;
1589 let anchor1 = ogeom_algo::edge_vertices(model, &rail1)?
1590 .map(|(a, _)| a)
1591 .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "a strip rail has no vertex"))?;
1592 let seam = make_edge_between(model, seam_curve, u_dom, &anchor0, &anchor1, tol)?.shape;
1593
1594 let row_line = |v: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1595 Ok(Line2d::over(
1596 ogeom_math::Axis2::new(Point2::new(0.0, v), ogeom_math::Direction2::X),
1597 u_dom.0 - 1.0,
1598 u_dom.1 + 1.0,
1599 )?
1600 .into())
1601 };
1602 let column_line = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1603 Ok(Line2d::over(
1604 ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
1605 v_dom.0 - 1.0,
1606 v_dom.1 + 1.0,
1607 )?
1608 .into())
1609 };
1610 ogeom_algo::attach_pcurve(
1611 model,
1612 &rail0,
1613 column_line(u_dom.0)?,
1614 surface_id,
1615 ogeom_topo::Location::identity(),
1616 v_dom,
1617 )?;
1618 ogeom_algo::attach_pcurve(
1619 model,
1620 &rail1,
1621 column_line(u_dom.1)?,
1622 surface_id,
1623 ogeom_topo::Location::identity(),
1624 v_dom,
1625 )?;
1626 ogeom_algo::attach_seam(
1627 model,
1628 &seam,
1629 row_line(v_dom.0)?,
1630 row_line(v_dom.1)?,
1631 surface_id,
1632 ogeom_topo::Location::identity(),
1633 u_dom,
1634 )?;
1635 let wire = ogeom_algo::make_wire(
1636 model,
1637 &[
1638 rail0.clone(),
1639 seam.clone(),
1640 rail1.reversed(),
1641 seam.reversed(),
1642 ],
1643 tol,
1644 )?
1645 .shape;
1646 let face = ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
1647 let mid_u = f64::midpoint(u_dom.0, u_dom.1);
1648 let mid_v = f64::midpoint(v_dom.0, v_dom.1);
1649 let s_mid = surface_geo.point_at(mid_u, mid_v, tol)?;
1650 let (du, dv) = surface_geo.d1_at(mid_u, mid_v, tol)?;
1651 let face = if du.cross(dv).dot(s_mid - outward_hint) >= 0.0 {
1652 face
1653 } else {
1654 face.reversed()
1655 };
1656 Ok((face, rail0, rail1))
1657}
1658
1659fn state_new_gaps(
1666 model: &mut Model,
1667 given: &[Shape],
1668 built: &Shape,
1669 tol: Tolerances,
1670) -> OgeomResult<()> {
1671 let mut held = ogeom_core::FastSet::default();
1672 for shape in given {
1673 for edge in ogeom_topo::explore_unique(model, shape, ShapeType::Edge)? {
1674 held.insert(edge.node());
1675 }
1676 }
1677 let made: Vec<Shape> = ogeom_topo::explore_unique(model, built, ShapeType::Edge)?
1678 .into_iter()
1679 .filter(|e| !held.contains(&e.node()))
1680 .collect();
1681 ogeom_algo::state_pcurve_gaps_of(model, &made, tol)
1682}
1683
1684fn adopt_border(
1690 model: &mut Model,
1691 edge: &Shape,
1692 surface: &SurfaceGeometry,
1693 slack: f64,
1694 tol: Tolerances,
1695) -> OgeomResult<()> {
1696 let (curve, range) = spine_curve_of(model, edge)?;
1697 let mut off: f64 = 0.0;
1698 for step in 0..=32 {
1699 #[allow(clippy::cast_precision_loss)]
1700 let t = range.0 + (range.1 - range.0) * (step as f64) / 32.0;
1701 let p = curve.point_at(t, tol)?;
1702 off = off.max(ogeom_algo::project_on_surface(surface, p, 16, tol)?.distance);
1703 }
1704 model.widen(edge, ogeom_core::Tolerance::new(off + slack)?)?;
1705 if let Some((a, b)) = ogeom_algo::edge_vertices(model, edge)? {
1706 for v in [&a, &b] {
1707 model.widen(v, ogeom_core::Tolerance::new(off + slack)?)?;
1708 }
1709 }
1710 Ok(())
1711}
1712
1713fn adopted_image(
1720 model: &Model,
1721 edge: &Shape,
1722 surface: &SurfaceGeometry,
1723 tol: Tolerances,
1724) -> OgeomResult<(ogeom_geom::PlanarCurve, (f64, f64), f64)> {
1725 const SAMPLES: u32 = 64;
1726 let (curve, range) = spine_curve_of(model, edge)?;
1727 let mut params = Vec::with_capacity(SAMPLES as usize + 1);
1728 let mut image = Vec::with_capacity(SAMPLES as usize + 1);
1729 let mut guess: Option<(f64, f64)> = None;
1730 for step in 0..=SAMPLES {
1731 let t = range.0 + (range.1 - range.0) * f64::from(step) / f64::from(SAMPLES);
1732 let p = curve.point_at(t, tol)?;
1733 let foot = match guess {
1734 Some(g) => ogeom_algo::project_on_surface_from(surface, p, g, tol)?,
1735 None => ogeom_algo::project_on_surface(surface, p, 16, tol)?,
1736 };
1737 guess = Some(foot.parameters);
1738 params.push(t);
1739 image.push(Point2::new(foot.parameters.0, foot.parameters.1));
1740 }
1741 use ogeom_geom::Surface as _;
1742 let fitted = ogeom_geom::fit::fit_points_2d_at(¶ms, &image, 3, tol.confusion(), tol)?;
1743 let ((u0, u1), (v0, v1)) = surface.domain();
1749 let (wraps_u, wraps_v) = (surface.is_periodic_u(), surface.is_periodic_v());
1750 let held: Vec<ogeom_math::Weighted<Point2>> = fitted
1751 .curve
1752 .control_points()
1753 .iter()
1754 .map(|w| {
1755 let p = w.point();
1756 ogeom_math::Weighted::new(
1757 Point2::new(
1758 if wraps_u { p.x } else { p.x.clamp(u0, u1) },
1759 if wraps_v { p.y } else { p.y.clamp(v0, v1) },
1760 ),
1761 w.weight,
1762 tol,
1763 )
1764 })
1765 .collect::<OgeomResult<_>>()?;
1766 let image = ogeom_geom::BSpline2d::rational(fitted.curve.knots().clone(), held)?;
1767 let mut off = 0.0_f64;
1768 for step in 0..=SAMPLES * 4 {
1769 let t = range.0 + (range.1 - range.0) * f64::from(step) / f64::from(SAMPLES * 4);
1770 let at = ogeom_geom::Curve2d::point_at(&image, t, tol)?;
1771 off = off.max(
1772 surface
1773 .point_at(at.x, at.y, tol)?
1774 .distance(curve.point_at(t, tol)?),
1775 );
1776 }
1777 Ok((image.into(), range, off))
1778}
1779
1780#[derive(Debug, Clone, Copy)]
1784enum EndCap {
1785 Plane(Vector),
1787 Skinned,
1790 Apex(Point),
1793}
1794
1795fn skinned_solid(
1796 model: &mut Model,
1797 skin: &Skin<'_>,
1798 caps: (EndCap, EndCap),
1799 tolerance: f64,
1800 tol: Tolerances,
1801) -> OgeomResult<Built> {
1802 let wall = skinned_wall(model, skin, (None, None), tolerance, tol)?;
1803 let rows = &skin.rows;
1804 let u_dom = wall.u_dom;
1805
1806 let cap = |model: &mut Model,
1807 ring: &Shape,
1808 curve: ogeom_geom::Curve,
1809 outward: Vector|
1810 -> OgeomResult<Shape> {
1811 let at = curve.point_at(u_dom.0, tol)?;
1812 let plane = Plane::through(at, Direction::new(outward, tol)?);
1813 let mut reach = 1.0_f64;
1814 for t in 0..8 {
1815 let p = curve.point_at(u_dom.0 + (u_dom.1 - u_dom.0) * f64::from(t) / 8.0, tol)?;
1816 reach = reach.max(p.distance(at) * 2.0);
1817 }
1818 let cap_surface: SurfaceGeometry =
1819 PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
1820 let walked = walked_about(model, std::slice::from_ref(ring), outward, true, tol)?;
1821 let wire = ogeom_algo::make_wire(model, &walked, tol)?.shape;
1822 let face =
1823 ogeom_algo::make_face(model, cap_surface.clone(), std::slice::from_ref(&wire), tol)?
1824 .shape;
1825 let id = {
1826 let Some(node) = model.node(&face) else {
1827 ogeom_bail!(Dangling, "the cap just built is not in this model");
1828 };
1829 let ogeom_topo::NodeData::Face(data) = node.data() else {
1830 ogeom_bail!(Construction, "the cap holds no face data");
1831 };
1832 data.surface
1833 };
1834 let Some(pcurve) = ogeom_intersect::exact_pcurve_of(&curve, &cap_surface, tol) else {
1835 ogeom_bail!(Construction, "a cap edge has no closed-form pcurve");
1836 };
1837 ogeom_algo::attach_pcurve(
1838 model,
1839 ring,
1840 pcurve,
1841 id,
1842 ogeom_topo::Location::identity(),
1843 u_dom,
1844 )?;
1845 Ok(face)
1846 };
1847 let close = |model: &mut Model,
1848 end: EndCap,
1849 ring: &Shape,
1850 curve: &ogeom_geom::Curve,
1851 row: &[Point],
1852 last: bool| {
1853 match end {
1854 EndCap::Plane(outward) => cap(model, ring, curve.clone(), outward),
1855 EndCap::Skinned | EndCap::Apex(_) => {
1856 let apex = match end {
1862 EndCap::Apex(apex) => apex,
1863 _ => centroid_of(std::slice::from_ref(&row.to_vec())),
1864 };
1865 let patch = match &skin.traced {
1866 Some(traced) => {
1869 let vs = &traced.sampling.vs;
1870 let at = if last { vs[vs.len() - 1] } else { vs[0] };
1871 Skin::swept(
1872 move |f, s| {
1873 let p = (traced.point)(f, at)?;
1874 Ok(apex + (p - apex) * (1.0 - s * 0.5))
1875 },
1876 traced.sampling.us.clone(),
1877 (0, 2),
1878 skin.round,
1879 )?
1880 }
1881 None => {
1882 let half: Vec<Point> = row
1883 .iter()
1884 .map(|p| Point::from_vector((p.to_vector() + apex.to_vector()) * 0.5))
1885 .collect();
1886 Skin::rows(vec![row.to_vec(), half, vec![apex; row.len()]])
1887 }
1888 };
1889 let inside = match end {
1893 EndCap::Apex(_) => None,
1894 _ => {
1895 let next = if last {
1896 &rows[rows.len() - 2]
1897 } else {
1898 &rows[1]
1899 };
1900 Some(centroid_of(std::slice::from_ref(next)))
1901 }
1902 };
1903 let Some((face, _)) =
1904 apex_patch(model, &patch, Some(ring), inside, None, tolerance, tol)?
1905 else {
1906 ogeom_bail!(Construction, "a cone over a section was refused");
1907 };
1908 Ok(face)
1909 }
1910 }
1911 };
1912 let cap0 = close(model, caps.0, &wall.ring0, &wall.curve0, &rows[0], false)?;
1913 let cap1 = close(
1914 model,
1915 caps.1,
1916 &wall.ring1,
1917 &wall.curve1,
1918 &rows[rows.len() - 1],
1919 true,
1920 )?;
1921
1922 let faces = [wall.face, cap0, cap1];
1923 let sewn = sew(model, &faces, tol)?;
1924 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
1925 ogeom_bail!(Construction, "the skinned solid did not close");
1926 }
1927 make_solid(model, std::slice::from_ref(&sewn.shells[0]))
1928}
1929
1930fn apex_patch(
1939 model: &mut Model,
1940 skin: &Skin<'_>,
1941 shared: Option<&Shape>,
1942 inside: Option<Point>,
1943 unfolded_from: Option<f64>,
1944 tolerance: f64,
1945 tol: Tolerances,
1946) -> OgeomResult<Option<(Shape, Shape)>> {
1947 use ogeom_geom::Surface as _;
1948 let rows = &skin.rows;
1949 let fitted = skin.fit(false, tolerance, tol)?;
1950 if !fitted.met {
1951 ogeom_bail!(
1952 NotDone,
1953 "the skin reached {} against a target of {tolerance}",
1954 fitted.error
1955 );
1956 }
1957 let surface = fitted.curve;
1958 let (u_knots, v_knots) = (surface.u_knots().clone(), surface.v_knots().clone());
1959 let (k, l, net) = {
1960 let grid = surface.grid();
1961 let net: Vec<Point> = grid.points().iter().map(|w| (*w).point()).collect();
1962 (grid.u_count(), grid.v_count(), net)
1963 };
1964 let point_at = |i: usize, j: usize| -> Point { net[i * l + j] };
1965 let (u_dom, v_dom) = surface.domain();
1966 let apex = rows[rows.len() - 1][0];
1967 let surface_geo: SurfaceGeometry = surface.into();
1968 let behind = inside.unwrap_or_else(|| centroid_of(std::slice::from_ref(&rows[0])));
1977 let spans = |knots: &ogeom_math::KnotVector| -> Vec<(f64, f64)> {
1978 let breaks: Vec<f64> = knots.distinct().into_iter().map(|(t, _)| t).collect();
1979 breaks.windows(2).map(|w| (w[0], w[1])).collect()
1980 };
1981 let mut held = 0.0;
1982 for (u0, u1) in spans(&u_knots) {
1983 for (v0, v1) in spans(&v_knots) {
1984 for (u, wu) in ogeom_math::integrate::gauss_legendre_rule(u0, u1) {
1985 for (v, wv) in ogeom_math::integrate::gauss_legendre_rule(v0, v1) {
1986 let p = surface_geo.point_at(u, v, tol)?;
1987 let (du, dv) = surface_geo.d1_at(u, v, tol)?;
1988 held += wu * wv * du.cross(dv).dot(p - behind);
1989 }
1990 }
1991 }
1992 }
1993 if let Some(v_from) = unfolded_from {
1994 let from = centroid_of(std::slice::from_ref(&rows[rows.len() - 2]));
1995 let u_count = (spans(&u_knots).len() * 4).max(256);
1996 if folds_toward(
1997 &surface_geo,
1998 (u_dom, (v_from, v_dom.1)),
1999 (from, apex),
2000 held,
2001 u_count,
2002 tol,
2003 )? {
2004 return Ok(None);
2005 }
2006 }
2007
2008 let ring_curve = {
2009 let control: Vec<Point> = (0..k).map(|i| point_at(i, 0)).collect();
2010 ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
2011 u_knots.clone(),
2012 control,
2013 tol,
2014 )?)
2015 };
2016 let seam_curve = {
2017 let control: Vec<Point> = (0..l).map(|j| point_at(0, j)).collect();
2018 ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
2019 v_knots.clone(),
2020 control,
2021 tol,
2022 )?)
2023 };
2024 let surface_id = model.geometry_mut().add_surface(surface_geo.clone());
2025
2026 let ring0 = match shared {
2028 Some(edge) => {
2029 adopt_border(
2030 model,
2031 edge,
2032 &surface_geo,
2033 fitted.error + tol.confusion(),
2034 tol,
2035 )?;
2036 edge.clone()
2037 }
2038 None => make_edge(model, ring_curve.clone(), u_dom, tol)?.shape,
2039 };
2040 let anchor0 = ogeom_algo::edge_vertices(model, &ring0)?
2041 .map(|(a, _)| a)
2042 .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "a skinned ring has no vertex"))?;
2043 let apex_vertex = model.add_vertex(VertexData::new(apex));
2044 let apex_edge = {
2045 let mut data = EdgeData::new();
2046 data.degenerate = true;
2047 model.add_edge(data, &[apex_vertex.clone(), apex_vertex.clone()])?
2048 };
2049 let seam = make_edge_between(model, seam_curve, v_dom, &anchor0, &apex_vertex, tol)?.shape;
2050
2051 let row_line = |v: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
2052 Ok(Line2d::over(
2053 ogeom_math::Axis2::new(Point2::new(0.0, v), ogeom_math::Direction2::X),
2054 u_dom.0 - 1.0,
2055 u_dom.1 + 1.0,
2056 )?
2057 .into())
2058 };
2059 let column_line = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
2060 Ok(Line2d::over(
2061 ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
2062 v_dom.0 - 1.0,
2063 v_dom.1 + 1.0,
2064 )?
2065 .into())
2066 };
2067 ogeom_algo::attach_pcurve(
2068 model,
2069 &ring0,
2070 row_line(v_dom.0)?,
2071 surface_id,
2072 ogeom_topo::Location::identity(),
2073 u_dom,
2074 )?;
2075 ogeom_algo::attach_pcurve(
2079 model,
2080 &apex_edge,
2081 row_line(v_dom.1)?,
2082 surface_id,
2083 ogeom_topo::Location::identity(),
2084 u_dom,
2085 )?;
2086 ogeom_algo::attach_seam(
2087 model,
2088 &seam,
2089 column_line(u_dom.0)?,
2090 column_line(u_dom.1)?,
2091 surface_id,
2092 ogeom_topo::Location::identity(),
2093 v_dom,
2094 )?;
2095
2096 let wire = ogeom_algo::make_wire(
2097 model,
2098 &[
2099 ring0.clone(),
2100 seam.clone(),
2101 apex_edge.reversed(),
2102 seam.reversed(),
2103 ],
2104 tol,
2105 )?
2106 .shape;
2107 let face = ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
2108 let face = if held >= 0.0 { face } else { face.reversed() };
2109 Ok(Some((face, ring0)))
2110}
2111
2112fn folds_toward(
2124 surface: &SurfaceGeometry,
2125 chart: ((f64, f64), (f64, f64)),
2126 ends: (Point, Point),
2127 outward: f64,
2128 u_count: usize,
2129 tol: Tolerances,
2130) -> OgeomResult<bool> {
2131 use ogeom_geom::Surface as _;
2132 const EVEN: u32 = 48;
2134 const CLOSING: i32 = 12;
2136 let ((u0, u1), (v0, v1)) = chart;
2137 let (from, to) = ends;
2138 let along = to - from;
2139 let length = along.magnitude();
2140 if length <= tol.confusion() {
2141 return Ok(true);
2142 }
2143 let axis = along / length;
2144 let outward = outward.signum();
2145 let mut vs: Vec<f64> = (0..EVEN)
2146 .map(|k| v0 + (v1 - v0) * f64::from(k) / f64::from(EVEN))
2147 .collect();
2148 vs.extend((1..=CLOSING).map(|k| v1 - (v1 - v0) / f64::from(EVEN) * 0.5_f64.powi(k)));
2149 #[allow(clippy::cast_precision_loss, reason = "a sample count")]
2150 let us: Vec<f64> = (0..u_count)
2151 .map(|i| u0 + (u1 - u0) * i as f64 / u_count as f64)
2152 .collect();
2153 let mut heights: Vec<f64> = vec![f64::NEG_INFINITY; us.len()];
2154 let mut sense = 0.0;
2155 for &v in &vs {
2156 let mut turned = 0.0;
2157 let mut first: Option<Vector> = None;
2158 let mut previous: Option<Vector> = None;
2159 for (i, &u) in us.iter().enumerate() {
2160 let p = surface.point_at(u, v, tol)?;
2161 let (du, dv) = surface.d1_at(u, v, tol)?;
2162 let off = p - from;
2163 let height = off.dot(axis);
2164 let radial = off - axis * height;
2165 if radial.magnitude() <= tol.confusion()
2166 || height <= heights[i]
2167 || du.cross(dv).dot(radial) * outward <= 0.0
2168 {
2169 return Ok(true);
2170 }
2171 heights[i] = height;
2172 if let Some(last) = previous {
2173 turned += last.cross(radial).dot(axis).atan2(last.dot(radial));
2174 }
2175 first.get_or_insert(radial);
2176 previous = Some(radial);
2177 }
2178 if let (Some(last), Some(start)) = (previous, first) {
2179 turned += last.cross(start).dot(axis).atan2(last.dot(start));
2180 }
2181 let round = turned / core::f64::consts::TAU;
2182 if (round.abs() - 1.0).abs() > 0.5 || round * sense < 0.0 {
2183 return Ok(true);
2184 }
2185 sense = round;
2186 }
2187 Ok(false)
2188}
2189
2190fn centroid_of(rows: &[Vec<Point>]) -> Point {
2192 let mut c = Vector::new(0.0, 0.0, 0.0);
2193 let mut n = 0.0;
2194 for row in rows {
2195 for p in row {
2196 c += p.to_vector();
2197 n += 1.0;
2198 }
2199 }
2200 Point::from_vector(c / n)
2201}
2202
2203fn skinned_solid_to_apex(
2206 model: &mut Model,
2207 skin: &Skin<'_>,
2208 cap_outward: Vector,
2209 unfolded_from: Option<f64>,
2210 tolerance: f64,
2211 tol: Tolerances,
2212) -> OgeomResult<Option<Built>> {
2213 use ogeom_geom::Curve3d as _;
2214 let Some((wall, ring0)) = apex_patch(model, skin, None, None, unfolded_from, tolerance, tol)?
2215 else {
2216 return Ok(None);
2217 };
2218 let (ring_curve, u_dom) = {
2219 let (curve, range) = spine_curve_of(model, &ring0)?;
2220 (curve, range)
2221 };
2222
2223 let cap = {
2226 let at = ring_curve.point_at(u_dom.0, tol)?;
2227 let plane = Plane::through(at, Direction::new(cap_outward, tol)?);
2228 let mut reach = 1.0_f64;
2229 for t in 0..8 {
2230 let p = ring_curve.point_at(u_dom.0 + (u_dom.1 - u_dom.0) * f64::from(t) / 8.0, tol)?;
2231 reach = reach.max(p.distance(at) * 2.0);
2232 }
2233 let cap_surface: SurfaceGeometry =
2234 PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
2235 let walked = walked_about(model, std::slice::from_ref(&ring0), cap_outward, true, tol)?;
2236 let wire = ogeom_algo::make_wire(model, &walked, tol)?.shape;
2237 let face =
2238 ogeom_algo::make_face(model, cap_surface.clone(), std::slice::from_ref(&wire), tol)?
2239 .shape;
2240 let id = {
2241 let Some(node) = model.node(&face) else {
2242 ogeom_bail!(Dangling, "the cap just built is not in this model");
2243 };
2244 let ogeom_topo::NodeData::Face(data) = node.data() else {
2245 ogeom_bail!(Construction, "the cap holds no face data");
2246 };
2247 data.surface
2248 };
2249 let Some(pcurve) = ogeom_intersect::exact_pcurve_of(&ring_curve, &cap_surface, tol) else {
2250 ogeom_bail!(Construction, "a cap edge has no closed-form pcurve");
2251 };
2252 ogeom_algo::attach_pcurve(
2253 model,
2254 &ring0,
2255 pcurve,
2256 id,
2257 ogeom_topo::Location::identity(),
2258 u_dom,
2259 )?;
2260 face
2261 };
2262
2263 let faces = [wall, cap];
2264 let sewn = sew(model, &faces, tol)?;
2265 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
2266 ogeom_bail!(Construction, "the skinned apex solid did not close");
2267 }
2268 make_solid(model, std::slice::from_ref(&sewn.shells[0])).map(Some)
2269}
2270
2271fn closed_skinned_solid(
2279 model: &mut Model,
2280 skin: &Skin<'_>,
2281 tolerance: f64,
2282 tol: Tolerances,
2283) -> OgeomResult<Built> {
2284 let shell = closed_skinned_shell(model, skin, tolerance, tol)?;
2285 make_solid(model, std::slice::from_ref(&shell))
2286}
2287
2288fn closed_skinned_shell(
2291 model: &mut Model,
2292 skin: &Skin<'_>,
2293 tolerance: f64,
2294 tol: Tolerances,
2295) -> OgeomResult<Shape> {
2296 use ogeom_geom::Surface as _;
2297 let rows = &skin.rows;
2298 let fitted = skin.fit(true, tolerance, tol)?;
2299 if !fitted.met {
2300 ogeom_bail!(
2301 NotDone,
2302 "the closed skin reached {} against a target of {tolerance}",
2303 fitted.error
2304 );
2305 }
2306 let surface = fitted.curve;
2307 let (u_knots, v_knots) = (surface.u_knots().clone(), surface.v_knots().clone());
2308 let (k, l, net) = {
2309 let grid = surface.grid();
2310 let net: Vec<Point> = grid.points().iter().map(|w| (*w).point()).collect();
2311 (grid.u_count(), grid.v_count(), net)
2312 };
2313 let point_at = |i: usize, j: usize| -> Point { net[i * l + j] };
2314 let (u_dom, v_dom) = surface.domain();
2315
2316 let along_u = {
2319 let control: Vec<Point> = (0..k).map(|i| point_at(i, 0)).collect();
2320 ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(u_knots, control, tol)?)
2321 };
2322 let along_v = {
2323 let control: Vec<Point> = (0..l).map(|j| point_at(0, j)).collect();
2324 ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(v_knots, control, tol)?)
2325 };
2326 let surface_geo: SurfaceGeometry = surface.into();
2327 let surface_id = model.geometry_mut().add_surface(surface_geo.clone());
2328
2329 let u_edge = make_edge(model, along_u, u_dom, tol)?.shape;
2330 let Some((corner, _)) = ogeom_algo::edge_vertices(model, &u_edge)? else {
2331 ogeom_bail!(Construction, "the closed skin's seam has no vertex");
2332 };
2333 let v_edge = make_edge_between(model, along_v, v_dom, &corner, &corner, tol)?.shape;
2334
2335 let row_line = |v: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
2336 Ok(Line2d::over(
2337 ogeom_math::Axis2::new(Point2::new(0.0, v), ogeom_math::Direction2::X),
2338 u_dom.0 - 1.0,
2339 u_dom.1 + 1.0,
2340 )?
2341 .into())
2342 };
2343 let column_line = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
2344 Ok(Line2d::over(
2345 ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
2346 v_dom.0 - 1.0,
2347 v_dom.1 + 1.0,
2348 )?
2349 .into())
2350 };
2351 ogeom_algo::attach_seam(
2354 model,
2355 &u_edge,
2356 row_line(v_dom.0)?,
2357 row_line(v_dom.1)?,
2358 surface_id,
2359 ogeom_topo::Location::identity(),
2360 u_dom,
2361 )?;
2362 ogeom_algo::attach_seam(
2363 model,
2364 &v_edge,
2365 column_line(u_dom.1)?,
2366 column_line(u_dom.0)?,
2367 surface_id,
2368 ogeom_topo::Location::identity(),
2369 v_dom,
2370 )?;
2371
2372 let wire = ogeom_algo::make_wire(
2373 model,
2374 &[
2375 u_edge.clone(),
2376 v_edge.clone(),
2377 u_edge.reversed(),
2378 v_edge.reversed(),
2379 ],
2380 tol,
2381 )?
2382 .shape;
2383 let face = ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
2384 let mid_u = f64::midpoint(u_dom.0, u_dom.1);
2387 let mid_v = f64::midpoint(v_dom.0, v_dom.1);
2388 let s_mid = surface_geo.point_at(mid_u, mid_v, tol)?;
2389 let (du, dv) = surface_geo.d1_at(mid_u, mid_v, tol)?;
2390 let face = if du.cross(dv).dot(s_mid - section_centre(rows, s_mid)) >= 0.0 {
2391 face
2392 } else {
2393 face.reversed()
2394 };
2395
2396 let sewn = sew(model, std::slice::from_ref(&face), tol)?;
2397 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
2398 ogeom_bail!(Construction, "the closed skin did not close");
2399 }
2400 Ok(sewn.shells[0].clone())
2401}
2402
2403fn loft_to_point(
2409 model: &mut Model,
2410 section: &Shape,
2411 apex: &Shape,
2412 tol: Tolerances,
2413) -> OgeomResult<Built> {
2414 if !ogeom_algo::is_wire_closed(model, section, tol)? {
2415 ogeom_bail!(Construction, "a loft section must be closed");
2416 }
2417 let apex_point = {
2418 let Some(data) = model.node(apex).and_then(|n| n.data().as_vertex()) else {
2419 ogeom_bail!(Construction, "the apex vertex holds no data");
2420 };
2421 apex.transform(model.datums())?.apply(data.point)
2422 };
2423
2424 let edges = explore(model, section, Filter::OfType(ShapeType::Edge))?;
2426 if edges.len() == 1
2427 && let Some((Curve::Circle(c), _)) = ogeom_algo::placed_edge_curve(model, &edges[0], tol)?
2428 {
2429 let circle = c.circle();
2430 let axis = circle.frame().z().vector();
2431 let rise = apex_point - circle.centre();
2432 let height = rise.dot(axis);
2433 if rise.cross(axis).magnitude() > tol.confusion() * 10.0 {
2434 ogeom_bail!(
2435 Construction,
2436 "a circle lofts to a point on its own axis; the oblique cone \
2437 needs the skinned machinery; see docs/CAPABILITIES.md, offset.loft"
2438 );
2439 }
2440 if height.abs() <= tol.confusion() {
2441 ogeom_bail!(Construction, "the apex sits in the section's own plane");
2442 }
2443 let base = if height > 0.0 {
2444 circle.frame()
2445 } else {
2446 Frame::new(
2447 circle.centre(),
2448 -circle.frame().z(),
2449 circle.frame().x(),
2450 tol,
2451 )?
2452 };
2453 let mut built =
2454 ogeom_algo::make_cone(model, base, circle.radius(), 0.0, height.abs(), tol)?;
2455 built.history.generate(section, built.shape.clone());
2456 built.history.generate(apex, built.shape.clone());
2457 return Ok(built);
2458 }
2459
2460 let mut corners: Vec<Point> = Vec::new();
2462 for edge in model.ordered_children_of(section)? {
2463 let Some((curve, range)) = ogeom_algo::placed_edge_curve(model, &edge, tol)? else {
2464 ogeom_bail!(Construction, "a section edge has no curve");
2465 };
2466 let Curve::Line(line) = curve else {
2467 ogeom_bail!(
2468 Construction,
2469 "a mixed or curved section lofts to a point through the \
2470 skinned machinery; see docs/CAPABILITIES.md, offset.loft"
2471 );
2472 };
2473 let t = if edge.orientation() == ogeom_topo::Orientation::Reversed {
2474 range.1
2475 } else {
2476 range.0
2477 };
2478 corners.push(ogeom_geom::Curve::Line(line).point_at(t, tol)?);
2479 }
2480 if corners.len() < 3 {
2481 ogeom_bail!(Construction, "a pyramid needs at least three base corners");
2482 }
2483 let apex_vertex = ogeom_algo::make_vertex(model, apex_point).shape;
2484 let base_vertices: Vec<Shape> = corners
2485 .iter()
2486 .map(|p| ogeom_algo::make_vertex(model, *p).shape)
2487 .collect();
2488 let segment =
2489 |model: &mut Model, from: (&Shape, Point), to: (&Shape, Point)| -> OgeomResult<Shape> {
2490 let line = ogeom_geom::LineCurve::segment(from.1, to.1, tol)?;
2491 let curve: Curve = line.into();
2492 let domain = curve.domain();
2493 Ok(make_edge_between(model, curve, domain, from.0, to.0, tol)?.shape)
2494 };
2495 let count = corners.len();
2496 let mut base_edges = Vec::with_capacity(count);
2497 let mut rails = Vec::with_capacity(count);
2498 for i in 0..count {
2499 let next = (i + 1) % count;
2500 base_edges.push(segment(
2501 model,
2502 (&base_vertices[i], corners[i]),
2503 (&base_vertices[next], corners[next]),
2504 )?);
2505 rails.push(segment(
2506 model,
2507 (&base_vertices[i], corners[i]),
2508 (&apex_vertex, apex_point),
2509 )?);
2510 }
2511 let centroid = {
2512 let mut c = Vector::new(0.0, 0.0, 0.0);
2513 for p in &corners {
2514 c += p.to_vector();
2515 }
2516 #[allow(clippy::cast_precision_loss)]
2517 Point::from_vector(c / count as f64 / 4.0 * 3.0 + apex_point.to_vector() / 4.0)
2518 };
2519 let planar = |model: &mut Model, pts: [Point; 3], walk: Vec<Shape>| -> OgeomResult<Shape> {
2520 let n = (pts[1] - pts[0]).cross(pts[2] - pts[0]);
2521 let m = n.magnitude();
2522 if m <= tol.confusion() {
2523 ogeom_bail!(Construction, "a wall of the pyramid is degenerate");
2524 }
2525 let mut outward = n / m;
2526 if outward.dot(pts[0] - centroid) < 0.0 {
2527 outward = -outward;
2528 }
2529 let plane = ogeom_math::Plane::through(pts[0], Direction::new(outward, tol)?);
2530 let mut reach = 1.0_f64;
2531 for p in pts {
2532 reach = reach.max(p.distance(pts[0]) * 2.0);
2533 }
2534 let surface: SurfaceGeometry =
2535 PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
2536 let id = model.geometry_mut().add_surface(surface.clone());
2537 let signed = {
2538 let (du, dv) = {
2539 use ogeom_geom::Surface as _;
2540 surface.d1_at(0.0, 0.0, tol)?
2541 };
2542 du.cross(dv).dot(outward) >= 0.0
2543 };
2544 let mut wired = Vec::with_capacity(walk.len());
2545 for used in &walk {
2546 let (curve, range) = spine_curve_of(model, used)?;
2547 let Some(pcurve) = ogeom_intersect::exact_pcurve_of(&curve, &surface, tol) else {
2548 ogeom_bail!(Construction, "a wall edge has no closed-form pcurve");
2549 };
2550 ogeom_algo::attach_pcurve(
2551 model,
2552 used,
2553 pcurve,
2554 id,
2555 ogeom_topo::Location::identity(),
2556 range,
2557 )?;
2558 wired.push(used.clone());
2559 }
2560 let wire = ogeom_algo::make_wire(model, &wired, tol)?.shape;
2561 let face = ogeom_algo::make_face_on(model, id, std::slice::from_ref(&wire), tol)?.shape;
2562 Ok(if signed { face } else { face.reversed() })
2563 };
2564 let mut faces = Vec::with_capacity(count + 1);
2565 for i in 0..count {
2566 let next = (i + 1) % count;
2567 faces.push(planar(
2568 model,
2569 [corners[i], corners[next], apex_point],
2570 vec![
2571 base_edges[i].clone(),
2572 rails[next].clone(),
2573 rails[i].reversed(),
2574 ],
2575 )?);
2576 }
2577 let base_walk: Vec<Shape> = (0..count).rev().map(|i| base_edges[i].reversed()).collect();
2579 faces.push({
2580 let n = (corners[1] - corners[0]).cross(corners[2] - corners[0]);
2581 let mut outward = n / n.magnitude();
2582 if outward.dot(corners[0] - centroid) < 0.0 {
2583 outward = -outward;
2584 }
2585 let plane = ogeom_math::Plane::through(corners[0], Direction::new(outward, tol)?);
2586 let mut reach = 1.0_f64;
2587 for p in &corners {
2588 reach = reach.max(p.distance(corners[0]) * 2.0);
2589 }
2590 let surface: SurfaceGeometry =
2591 PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
2592 let id = model.geometry_mut().add_surface(surface.clone());
2593 for used in &base_walk {
2594 let (curve, range) = spine_curve_of(model, used)?;
2595 let Some(pcurve) = ogeom_intersect::exact_pcurve_of(&curve, &surface, tol) else {
2596 ogeom_bail!(Construction, "a base edge has no closed-form pcurve");
2597 };
2598 ogeom_algo::attach_pcurve(
2599 model,
2600 used,
2601 pcurve,
2602 id,
2603 ogeom_topo::Location::identity(),
2604 range,
2605 )?;
2606 }
2607 let wire = ogeom_algo::make_wire(model, &base_walk, tol)?.shape;
2608 let face = ogeom_algo::make_face_on(model, id, std::slice::from_ref(&wire), tol)?.shape;
2609 let signed = {
2610 use ogeom_geom::Surface as _;
2611 let (du, dv) = surface.d1_at(0.0, 0.0, tol)?;
2612 du.cross(dv).dot(outward) >= 0.0
2613 };
2614 if signed { face } else { face.reversed() }
2615 });
2616
2617 let sewn = sew(model, &faces, tol)?;
2618 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
2619 ogeom_bail!(Construction, "the pyramid did not close");
2620 }
2621 let mut built = make_solid(model, std::slice::from_ref(&sewn.shells[0]))?;
2622 built.history.generate(section, built.shape.clone());
2623 built.history.generate(apex, built.shape.clone());
2624 Ok(built)
2625}
2626
2627pub fn make_loft_skinned_aligned(
2639 model: &mut Model,
2640 sections: &[Shape],
2641 hints: &[Point],
2642 tolerance: f64,
2643 tol: Tolerances,
2644) -> OgeomResult<Built> {
2645 if hints.len() != sections.len() {
2646 ogeom_bail!(
2647 Construction,
2648 "{} hints against {} sections; each section names its own start",
2649 hints.len(),
2650 sections.len()
2651 );
2652 }
2653 if sections.len() < 2 {
2654 ogeom_bail!(Construction, "a loft needs at least two sections");
2655 }
2656 let mut loops: Vec<Section> = Vec::with_capacity(sections.len());
2657 let mut planes: Vec<Plane> = Vec::with_capacity(sections.len());
2658 for (wire, hint) in sections.iter().zip(hints) {
2659 if model.kind_of(wire)? != ShapeType::Wire {
2660 ogeom_bail!(Construction, "a loft section is a closed wire");
2661 }
2662 if !ogeom_algo::is_wire_closed(model, wire, tol)? {
2663 ogeom_bail!(Construction, "a loft section must be closed");
2664 }
2665 let Some(plane) = ogeom_algo::find_plane(model, wire, tol)? else {
2666 ogeom_bail!(Construction, "a loft section must be planar");
2667 };
2668 planes.push(plane);
2669 loops.push(Section::Loop(section_loop(model, wire, Some(*hint), tol)?));
2670 }
2671 let skin = Skin::sections(loops, false)?;
2672 let rows = &skin.rows;
2673 let outward0 = {
2674 let towards = rows[1][0] - rows[0][0];
2675 let n = planes[0].normal().vector();
2676 if n.dot(towards) > 0.0 { -n } else { n }
2677 };
2678 let outward1 = {
2679 let towards = rows[rows.len() - 2][0] - rows[rows.len() - 1][0];
2680 let n = planes[planes.len() - 1].normal().vector();
2681 if n.dot(towards) > 0.0 { -n } else { n }
2682 };
2683 let mut built = skinned_solid(
2684 model,
2685 &skin,
2686 (EndCap::Plane(outward0), EndCap::Plane(outward1)),
2687 tolerance,
2688 tol,
2689 )?;
2690 for section in sections {
2691 built.history.generate(section, built.shape.clone());
2692 }
2693 state_new_gaps(model, sections, &built.shape, tol)?;
2694 Ok(built)
2695}
2696
2697pub fn make_loft_skinned_closed(
2716 model: &mut Model,
2717 sections: &[Shape],
2718 tolerance: f64,
2719 tol: Tolerances,
2720) -> OgeomResult<Built> {
2721 if sections.len() < 3 {
2722 ogeom_bail!(Construction, "a closed loft needs at least three sections");
2723 }
2724 let mut loops: Vec<Section> = Vec::with_capacity(sections.len());
2725 for wire in sections {
2726 if model.kind_of(wire)? != ShapeType::Wire {
2727 ogeom_bail!(Construction, "a loft section is a closed wire");
2728 }
2729 if !ogeom_algo::is_wire_closed(model, wire, tol)? {
2730 ogeom_bail!(Construction, "a loft section must be closed");
2731 }
2732 if ogeom_algo::find_plane(model, wire, tol)?.is_none() {
2733 ogeom_bail!(Construction, "a loft section must be planar");
2734 }
2735 loops.push(Section::Loop(section_loop(model, wire, None, tol)?));
2736 }
2737 let skin = Skin::sections(loops, true)?;
2738 let mut built = closed_skinned_solid(model, &skin, tolerance, tol)?;
2739 for section in sections {
2740 built.history.generate(section, built.shape.clone());
2741 }
2742 state_new_gaps(model, sections, &built.shape, tol)?;
2743 Ok(built)
2744}
2745
2746struct SkinnedStrip {
2753 face: Shape,
2754 bottom: Shape,
2756 top: Shape,
2758 rail0: Shape,
2760 rail1: Shape,
2762}
2763
2764#[allow(clippy::too_many_arguments, reason = "one strip, spelled out")]
2771fn skinned_strip(
2772 model: &mut Model,
2773 skin: &Skin<'_>,
2774 corners: (&Shape, &Shape, &Shape, &Shape),
2775 shared: [Option<&Shape>; 4],
2776 outward_hint: Point,
2777 hole: bool,
2778 tolerance: f64,
2779 tol: Tolerances,
2780) -> OgeomResult<SkinnedStrip> {
2781 use ogeom_geom::Surface as _;
2782 let rows = &skin.rows;
2783 if let Some(plane) = plane_of_rows(rows, tol) {
2788 return planar_strip(
2789 model,
2790 skin,
2791 plane,
2792 corners,
2793 shared,
2794 outward_hint,
2795 hole,
2796 tolerance,
2797 tol,
2798 );
2799 }
2800 let fitted = skin.fit(false, tolerance, tol)?;
2804 if !fitted.met {
2805 ogeom_bail!(
2806 NotDone,
2807 "the strip reached {} against a target of {tolerance}",
2808 fitted.error
2809 );
2810 }
2811 let error = fitted.error.max(tol.confusion());
2812 let surface = fitted.curve;
2813 let (u_knots, v_knots) = (surface.u_knots().clone(), surface.v_knots().clone());
2814 let (k, l, net) = {
2815 let grid = surface.grid();
2816 let net: Vec<Point> = grid.points().iter().map(|w| (*w).point()).collect();
2817 (grid.u_count(), grid.v_count(), net)
2818 };
2819 let point_at = |i: usize, j: usize| -> Point { net[i * l + j] };
2820 let (u_dom, v_dom) = surface.domain();
2821
2822 let u_curve = |j: usize| -> OgeomResult<ogeom_geom::Curve> {
2823 let control: Vec<Point> = (0..k).map(|i| point_at(i, j)).collect();
2824 Ok(ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
2825 u_knots.clone(),
2826 control,
2827 tol,
2828 )?))
2829 };
2830 let v_curve = |i: usize| -> OgeomResult<ogeom_geom::Curve> {
2831 let control: Vec<Point> = (0..l).map(|j| point_at(i, j)).collect();
2832 Ok(ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
2833 v_knots.clone(),
2834 control,
2835 tol,
2836 )?))
2837 };
2838 let surface_geo: SurfaceGeometry = surface.into();
2839 let surface_id = model.geometry_mut().add_surface(surface_geo.clone());
2840
2841 let (c00, c10, c01, c11) = corners;
2842 let border = |model: &mut Model,
2845 given: Option<&Shape>,
2846 curve: ogeom_geom::Curve,
2847 range: (f64, f64),
2848 from: &Shape,
2849 to: &Shape|
2850 -> OgeomResult<Shape> {
2851 match given {
2852 Some(edge) => {
2853 adopt_border(model, edge, &surface_geo, error + tol.confusion(), tol)?;
2854 Ok(edge.clone())
2855 }
2856 None => Ok(make_edge_between(model, curve, range, from, to, tol)?.shape),
2857 }
2858 };
2859 let bottom = border(model, shared[0], u_curve(0)?, u_dom, c00, c10)?;
2860 let top = border(model, shared[1], u_curve(l - 1)?, u_dom, c01, c11)?;
2861 let rail0 = border(model, shared[2], v_curve(0)?, v_dom, c00, c01)?;
2862 let rail1 = border(model, shared[3], v_curve(k - 1)?, v_dom, c10, c11)?;
2863
2864 let row_line = |v: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
2865 Ok(Line2d::over(
2866 ogeom_math::Axis2::new(Point2::new(0.0, v), ogeom_math::Direction2::X),
2867 u_dom.0 - 1.0,
2868 u_dom.1 + 1.0,
2869 )?
2870 .into())
2871 };
2872 let column_line = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
2873 Ok(Line2d::over(
2874 ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
2875 v_dom.0 - 1.0,
2876 v_dom.1 + 1.0,
2877 )?
2878 .into())
2879 };
2880 for (edge, given, straight, span) in [
2885 (&bottom, shared[0].is_some(), row_line(v_dom.0)?, u_dom),
2886 (&top, shared[1].is_some(), row_line(v_dom.1)?, u_dom),
2887 (&rail0, shared[2].is_some(), column_line(u_dom.0)?, v_dom),
2888 (&rail1, shared[3].is_some(), column_line(u_dom.1)?, v_dom),
2889 ] {
2890 let (image, range) = if given {
2891 let (image, range, off) = adopted_image(model, edge, &surface_geo, tol)?;
2892 if off > tol.confusion() {
2895 let held = ogeom_core::Tolerance::new(off)?;
2896 model.widen(edge, held)?;
2897 if let Some((a, b)) = ogeom_algo::edge_vertices(model, edge)? {
2898 for v in [&a, &b] {
2899 model.widen(v, held)?;
2900 }
2901 }
2902 }
2903 (image, range)
2904 } else {
2905 (straight, span)
2906 };
2907 ogeom_algo::attach_pcurve(
2908 model,
2909 edge,
2910 image,
2911 surface_id,
2912 ogeom_topo::Location::identity(),
2913 range,
2914 )?;
2915 }
2916 for edge in [&bottom, &top, &rail0, &rail1] {
2920 model.widen(edge, ogeom_core::Tolerance::new(error)?)?;
2921 }
2922
2923 let wire = ogeom_algo::make_wire(
2924 model,
2925 &[
2926 bottom.clone(),
2927 rail1.clone(),
2928 top.reversed(),
2929 rail0.reversed(),
2930 ],
2931 tol,
2932 )?
2933 .shape;
2934 let face = ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
2935 let mid_u = f64::midpoint(u_dom.0, u_dom.1);
2936 let mid_v = f64::midpoint(v_dom.0, v_dom.1);
2937 let s_mid = surface_geo.point_at(mid_u, mid_v, tol)?;
2938 let (du, dv) = surface_geo.d1_at(mid_u, mid_v, tol)?;
2939 let natural_out = du.cross(dv).dot(s_mid - outward_hint) >= 0.0;
2940 let face = if natural_out == !hole {
2941 face
2942 } else {
2943 face.reversed()
2944 };
2945 Ok(SkinnedStrip {
2946 face,
2947 bottom,
2948 top,
2949 rail0,
2950 rail1,
2951 })
2952}
2953
2954fn plane_of_rows(rows: &[Vec<Point>], tol: Tolerances) -> Option<Plane> {
2956 let first = rows.first()?;
2957 let last = rows.last()?;
2958 let origin = *first.first()?;
2959 let across = *first.last()? - origin;
2960 let along = *last.first()? - origin;
2961 let normal = across.cross(along);
2962 if normal.magnitude() <= tol.confusion() * across.magnitude().max(along.magnitude()) {
2963 return None;
2964 }
2965 let normal = Direction::new(normal, tol).ok()?;
2966 let plane = Plane::through(origin, normal);
2967 rows.iter()
2968 .flatten()
2969 .all(|p| plane.distance_to(*p) <= tol.confusion())
2970 .then_some(plane)
2971}
2972
2973#[derive(Clone, Copy)]
2975enum Side {
2976 Row(usize),
2977 Column(usize),
2978}
2979
2980#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
2983fn planar_strip(
2984 model: &mut Model,
2985 skin: &Skin<'_>,
2986 plane: Plane,
2987 corners: (&Shape, &Shape, &Shape, &Shape),
2988 shared: [Option<&Shape>; 4],
2989 outward_hint: Point,
2990 hole: bool,
2991 tolerance: f64,
2992 tol: Tolerances,
2993) -> OgeomResult<SkinnedStrip> {
2994 let rows = &skin.rows;
2995 let through = |points: &[Point], side: Side| -> OgeomResult<ogeom_geom::Curve> {
3000 let fitted = match (&skin.traced, side) {
3001 (Some(traced), Side::Row(j)) => {
3002 let v = traced.sampling.vs[j];
3003 ogeom_geom::fit::fit_curve_sampled(
3004 |u| (traced.point)(u, v),
3005 &traced.sampling.us,
3006 false,
3007 3,
3008 tolerance * 0.5,
3009 tol,
3010 )?
3011 }
3012 (Some(traced), Side::Column(i)) if traced.sampling.between.1 => {
3013 let u = traced.sampling.us[i];
3014 ogeom_geom::fit::fit_curve_sampled(
3015 |v| (traced.point)(u, v),
3016 &traced.sampling.vs,
3017 false,
3018 3,
3019 tolerance * 0.5,
3020 tol,
3021 )?
3022 }
3023 _ => ogeom_geom::fit::fit_points(points, 3, tolerance * 0.5, tol)?,
3024 };
3025 if !fitted.met {
3026 ogeom_bail!(
3027 NotDone,
3028 "a planar strip's border reached {} against a target of {tolerance}",
3029 fitted.error
3030 );
3031 }
3032 Ok(ogeom_geom::Curve::BSpline(fitted.curve))
3033 };
3034 let column = |i: usize| -> Vec<Point> { rows.iter().map(|row| row[i]).collect() };
3035 let last = rows[0].len() - 1;
3036 let (c00, c10, c01, c11) = corners;
3037 let border = |model: &mut Model,
3038 given: Option<&Shape>,
3039 points: Vec<Point>,
3040 side: Side,
3041 from: &Shape,
3042 to: &Shape|
3043 -> OgeomResult<Shape> {
3044 if let Some(edge) = given {
3045 return Ok(edge.clone());
3046 }
3047 let curve = through(&points, side)?;
3048 let domain = curve.domain();
3049 Ok(make_edge_between(model, curve, domain, from, to, tol)?.shape)
3050 };
3051 let bottom = border(model, shared[0], rows[0].clone(), Side::Row(0), c00, c10)?;
3052 let top_row = rows.len() - 1;
3053 let top = border(
3054 model,
3055 shared[1],
3056 rows[top_row].clone(),
3057 Side::Row(top_row),
3058 c01,
3059 c11,
3060 )?;
3061 let rail0 = border(model, shared[2], column(0), Side::Column(0), c00, c01)?;
3062 let rail1 = border(model, shared[3], column(last), Side::Column(last), c10, c11)?;
3063
3064 let border: Vec<Point> = rows[0]
3069 .iter()
3070 .copied()
3071 .chain(rows.iter().skip(1).map(|row| row[last]))
3072 .chain(rows[top_row].iter().rev().skip(1).copied())
3073 .chain(
3074 rows.iter()
3075 .rev()
3076 .skip(1)
3077 .take(top_row.saturating_sub(1))
3078 .map(|row| row[0]),
3079 )
3080 .collect();
3081 let turn = (0..border.len())
3082 .map(|i| (border[i] - border[0]).cross(border[(i + 1) % border.len()] - border[0]))
3083 .fold(Vector::new(0.0, 0.0, 0.0), |sum, v| sum + v);
3084 let normal = if turn.dot(plane.normal().vector()) >= 0.0 {
3085 plane.normal()
3086 } else {
3087 plane.normal().reversed()
3088 };
3089 let wound = Plane::through(plane.origin(), normal);
3090 let reach = rows
3091 .iter()
3092 .flatten()
3093 .map(|p| p.distance(plane.origin()))
3094 .fold(1.0_f64, f64::max)
3095 * 2.0;
3096 let surface: SurfaceGeometry =
3097 PlaneSurface::over(wound, (-reach, reach), (-reach, reach))?.into();
3098 let face = ogeom_algo::make_face_with_pcurves(
3099 model,
3100 surface.clone(),
3101 &[vec![
3102 bottom.clone(),
3103 rail1.clone(),
3104 top.reversed(),
3105 rail0.reversed(),
3106 ]],
3107 tol,
3108 )?
3109 .shape;
3110 let mid = rows[rows.len() / 2][last / 2];
3111 let natural_out = normal.vector().dot(mid - outward_hint) >= 0.0;
3112 let face = if natural_out == !hole {
3113 face
3114 } else {
3115 face.reversed()
3116 };
3117 Ok(SkinnedStrip {
3118 face,
3119 bottom,
3120 top,
3121 rail0,
3122 rail1,
3123 })
3124}
3125
3126fn coaxial_circles_loft(
3131 model: &mut Model,
3132 sections: &[Shape],
3133 tol: Tolerances,
3134) -> OgeomResult<Option<Built>> {
3135 let mut circles = Vec::with_capacity(sections.len());
3136 for wire in sections {
3137 if model.kind_of(wire)? != ShapeType::Wire {
3138 return Ok(None);
3139 }
3140 let edges = model.ordered_children_of(wire)?;
3141 let [edge] = edges.as_slice() else {
3142 return Ok(None);
3143 };
3144 let (curve, _) = spine_curve_of(model, edge)?;
3145 let ogeom_geom::Curve::Circle(c) = curve else {
3146 return Ok(None);
3147 };
3148 let placed = c
3149 .circle()
3150 .transformed(&edge.transform(model.datums())?, tol)?;
3151 circles.push(placed);
3152 }
3153 let first = circles[0].frame();
3154 let (c0, z0) = (first.origin(), first.z().vector());
3155 let last = circles[circles.len() - 1].centre();
3156 let rise = last - c0;
3157 if rise.magnitude() <= tol.confusion() {
3158 return Ok(None);
3159 }
3160 let z = rise / rise.magnitude();
3161 if z.cross(z0).magnitude() > tol.angular() {
3162 return Ok(None);
3163 }
3164 let mut heights = Vec::with_capacity(circles.len());
3165 for c in &circles {
3166 let off = c.centre() - c0;
3167 if off.cross(z).magnitude() > tol.confusion() * 10.0
3168 || c.frame().z().vector().cross(z).magnitude() > tol.angular()
3169 {
3170 return Ok(None);
3171 }
3172 heights.push(off.dot(z));
3173 }
3174 if heights.windows(2).any(|w| w[1] <= w[0] + tol.confusion()) {
3175 return Ok(None);
3176 }
3177 let x = first.x().vector();
3178 let meridian: Vec<Point> = circles
3179 .iter()
3180 .zip(&heights)
3181 .map(|(c, h)| c0 + z * *h + x * c.radius())
3182 .collect();
3183 let degree = (meridian.len() - 1).min(3);
3184 let fitted = ogeom_geom::fit::fit_points(&meridian, degree, tol.confusion() * 1e-3, tol)?;
3185 let spline: ogeom_geom::Curve = fitted.curve.into();
3186 let domain = spline.domain();
3187 let top = c0 + z * heights[heights.len() - 1];
3188 let vertex = |model: &mut Model, p: Point| ogeom_algo::make_vertex(model, p).shape;
3189 let (v_axis0, v_axis1) = (vertex(model, c0), vertex(model, top));
3190 let (v_rim0, v_rim1) = (
3191 vertex(model, meridian[0]),
3192 vertex(model, meridian[meridian.len() - 1]),
3193 );
3194 let segment =
3195 |model: &mut Model, a: (&Shape, Point), b: (&Shape, Point)| -> OgeomResult<Shape> {
3196 let line: ogeom_geom::Curve = LineCurve::segment(a.1, b.1, tol)?.into();
3197 let range = line.domain();
3198 Ok(make_edge_between(model, line, range, a.0, b.0, tol)?.shape)
3199 };
3200 let bottom = segment(model, (&v_axis0, c0), (&v_rim0, meridian[0]))?;
3201 let side = make_edge_between(model, spline, domain, &v_rim0, &v_rim1, tol)?.shape;
3202 let top_edge = segment(
3203 model,
3204 (&v_rim1, meridian[meridian.len() - 1]),
3205 (&v_axis1, top),
3206 )?;
3207 let axis_edge = segment(model, (&v_axis1, top), (&v_axis0, c0))?;
3208 let wire = ogeom_algo::make_wire(model, &[bottom, side, top_edge, axis_edge], tol)?.shape;
3209 let inside = c0
3212 + z * (heights[heights.len() - 1] * 0.5)
3213 + x * (circles
3214 .iter()
3215 .map(|c| c.radius())
3216 .fold(f64::INFINITY, f64::min)
3217 * 0.5);
3218 let plane = Plane::new(Frame::new(
3219 inside,
3220 Direction::new(z.cross(x), tol)?,
3221 Direction::new(x, tol)?,
3222 tol,
3223 )?);
3224 let face = ogeom_algo::make_face(model, PlaneSurface::new(plane).into(), &[wire], tol)?.shape;
3225 let axis = ogeom_math::Axis {
3226 location: c0,
3227 direction: Direction::new(z, tol)?,
3228 };
3229 let built = ogeom_algo::make_revolution(model, &face, axis, core::f64::consts::TAU, tol)?;
3230 Ok(Some(built))
3231}
3232
3233fn cornered_loft(
3240 model: &mut Model,
3241 sections: &[Shape],
3242 along: Option<SectionMotion<'_>>,
3243 tolerance: f64,
3244 tol: Tolerances,
3245) -> OgeomResult<Option<Built>> {
3246 let mut rings: Vec<Vec<Shape>> = Vec::with_capacity(sections.len());
3247 for wire in sections {
3248 if model.kind_of(wire)? != ShapeType::Wire || !ogeom_algo::is_wire_closed(model, wire, tol)?
3249 {
3250 return Ok(None);
3251 }
3252 rings.push(model.ordered_children_of(wire)?);
3253 }
3254 let count = rings[0].len();
3255 if count < 2 || rings.iter().any(|r| r.len() != count) {
3256 return Ok(None);
3257 }
3258 let (Some(plane0), Some(plane1)) = (
3259 ogeom_algo::find_plane(model, §ions[0], tol)?,
3260 ogeom_algo::find_plane(model, §ions[sections.len() - 1], tol)?,
3261 ) else {
3262 return Ok(None);
3263 };
3264 const ALONG: usize = 16;
3265 let mut edge_curves: Vec<Vec<(ogeom_geom::Curve, (f64, f64))>> =
3268 Vec::with_capacity(rings.len());
3269 for ring in &rings {
3270 let mut per_edge = Vec::with_capacity(count);
3271 for edge in ring {
3272 let (curve, range) = spine_curve_of(model, edge)?;
3273 let curve = curve.transformed(&edge.transform(model.datums())?, tol)?;
3274 let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
3275 per_edge.push((curve, if reversed { (range.1, range.0) } else { range }));
3276 }
3277 edge_curves.push(per_edge);
3278 }
3279 let edge_at = |s: usize, e: usize, f: f64| -> OgeomResult<Point> {
3280 let (curve, (a, b)) = &edge_curves[s][e];
3281 curve.point_at(a + (b - a) * f, tol)
3282 };
3283 let samples: Vec<Vec<Vec<Point>>> = (0..rings.len())
3284 .map(|s| {
3285 (0..count)
3286 .map(|e| fractions(ALONG).iter().map(|f| edge_at(s, e, *f)).collect())
3287 .collect()
3288 })
3289 .collect::<OgeomResult<_>>()?;
3290 let corners = |model: &mut Model, s: usize| -> Vec<Shape> {
3291 (0..count)
3292 .map(|e| ogeom_algo::make_vertex(model, samples[s][e][0]).shape)
3293 .collect()
3294 };
3295 let (from, to) = (corners(model, 0), corners(model, sections.len() - 1));
3296 let middle = &samples[sections.len() / 2];
3297 let hint = {
3298 let all: Vec<Point> = middle.iter().flatten().copied().collect();
3299 #[allow(clippy::cast_precision_loss)]
3300 let n = all.len() as f64;
3301 Point::from_vector(
3302 all.iter()
3303 .fold(Vector::new(0.0, 0.0, 0.0), |acc, p| acc + p.to_vector())
3304 / n,
3305 )
3306 };
3307 let mut faces = Vec::with_capacity(count + 2);
3308 let (mut bottoms, mut tops) = (Vec::with_capacity(count), Vec::with_capacity(count));
3309 let mut first_rail: Option<Shape> = None;
3310 let mut prev_rail: Option<Shape> = None;
3311 for e in 0..count {
3312 let skin = match along {
3313 Some(motion) => Skin::swept(
3314 move |f, s| {
3315 if s.fract() == 0.0 {
3316 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
3317 let i = s as usize;
3318 return edge_at(i, e, f);
3319 }
3320 Ok(motion(s)?.apply(edge_at(0, e, f)?))
3321 },
3322 fractions(ALONG),
3323 (0, rings.len() - 1),
3324 false,
3325 )?,
3326 None => Skin::columns(
3327 |f| (0..rings.len()).map(|s| edge_at(s, e, f)).collect(),
3328 fractions(ALONG),
3329 false,
3330 true,
3331 false,
3332 )?,
3333 };
3334 let next = (e + 1) % count;
3335 let last_rail = if e + 1 == count {
3336 first_rail.clone()
3337 } else {
3338 None
3339 };
3340 let strip = skinned_strip(
3341 model,
3342 &skin,
3343 (&from[e], &from[next], &to[e], &to[next]),
3344 [None, None, prev_rail.as_ref(), last_rail.as_ref()],
3345 hint,
3346 false,
3347 tolerance,
3348 tol,
3349 )?;
3350 if e == 0 {
3351 first_rail = Some(strip.rail0.clone());
3352 }
3353 prev_rail = Some(strip.rail1.clone());
3354 faces.push(strip.face.clone());
3355 bottoms.push(strip.bottom);
3356 tops.push(strip.top);
3357 }
3358 let towards = hint - samples[0][0][0];
3359 let n0 = plane0.normal().vector();
3360 let n0 = if n0.dot(towards) > 0.0 { -n0 } else { n0 };
3361 let away = hint - samples[sections.len() - 1][0][0];
3362 let n1 = plane1.normal().vector();
3363 let n1 = if n1.dot(away) > 0.0 { -n1 } else { n1 };
3364 faces.push(plane_cap(model, samples[0][0][0], n0, &[bottoms], tol)?);
3365 faces.push(plane_cap(
3366 model,
3367 samples[sections.len() - 1][0][0],
3368 n1,
3369 &[tops],
3370 tol,
3371 )?);
3372 let sewn = sew(model, &faces, tol)?;
3373 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
3374 ogeom_bail!(Construction, "the cornered loft did not close");
3375 }
3376 Ok(Some(make_solid(model, &sewn.shells)?))
3377}
3378
3379fn plane_cap(
3383 model: &mut Model,
3384 at: Point,
3385 outward: Vector,
3386 loops: &[Vec<Shape>],
3387 tol: Tolerances,
3388) -> OgeomResult<Shape> {
3389 let cap_plane = Plane::through(at, Direction::new(outward, tol)?);
3390 let mut reach = 1.0_f64;
3391 for edges in loops {
3392 for edge in edges {
3393 let (curve, range) = spine_curve_of(model, edge)?;
3394 for k in 0..8 {
3395 let p = curve.point_at(range.0 + (range.1 - range.0) * f64::from(k) / 8.0, tol)?;
3396 reach = reach.max(p.distance(at) * 2.0);
3397 }
3398 }
3399 }
3400 let surface: SurfaceGeometry =
3401 PlaneSurface::over(cap_plane, (-reach, reach), (-reach, reach))?.into();
3402 let turns: Vec<f64> = loops
3406 .iter()
3407 .map(|edges| ring_turning(model, edges, outward, tol))
3408 .collect::<OgeomResult<_>>()?;
3409 let outer = turns
3410 .iter()
3411 .enumerate()
3412 .max_by(|a, b| a.1.abs().total_cmp(&b.1.abs()))
3413 .map_or(0, |(i, _)| i);
3414 let mut wires = Vec::with_capacity(loops.len());
3415 for (i, (edges, turn)) in loops.iter().zip(&turns).enumerate() {
3416 let ring = if (*turn > 0.0) == (i == outer) {
3417 edges.clone()
3418 } else {
3419 walked_back(edges)
3420 };
3421 wires.push(ogeom_algo::make_wire(model, &ring, tol)?.shape);
3422 }
3423 let face = ogeom_algo::make_face(model, surface, &wires, tol)?.shape;
3424 let cap_id = {
3425 let Some(ogeom_topo::NodeData::Face(data)) = model.node(&face).map(|n| n.data()) else {
3426 ogeom_bail!(Construction, "the cap holds no face data");
3427 };
3428 data.surface
3429 };
3430 let frame = cap_plane.frame();
3431 let flat = |p: Point| {
3432 let local = frame.to_local(p);
3433 Point2::new(local.x, local.y)
3434 };
3435 for edges in loops {
3436 for edge in edges {
3437 let (curve, range) = spine_curve_of(model, edge)?;
3438 let pcurve: ogeom_geom::PlanarCurve = match &curve {
3439 ogeom_geom::Curve::BSpline(bs) => {
3440 let control2: Vec<Point2> = bs
3441 .control_points()
3442 .iter()
3443 .map(|w| flat(w.point()))
3444 .collect();
3445 ogeom_geom::BSpline2d::new(bs.knots().clone(), control2, tol)?.into()
3446 }
3447 ogeom_geom::Curve::Line(line) => {
3448 let axis = line.axis();
3449 let origin = flat(axis.location);
3450 let ahead = flat(axis.location + axis.direction.vector());
3451 ogeom_geom::Line2d::over(
3452 ogeom_math::Axis2::through(origin, ahead, tol)?,
3453 range.0,
3454 range.1,
3455 )?
3456 .into()
3457 }
3458 _ => ogeom_bail!(Construction, "a cap edge is neither a spline nor a line"),
3459 };
3460 ogeom_algo::attach_pcurve(
3461 model,
3462 edge,
3463 pcurve,
3464 cap_id,
3465 ogeom_topo::Location::identity(),
3466 range,
3467 )?;
3468 }
3469 }
3470 Ok(face)
3471}
3472
3473pub fn make_loft_skinned(
3495 model: &mut Model,
3496 sections: &[Shape],
3497 tolerance: f64,
3498 tol: Tolerances,
3499) -> OgeomResult<Built> {
3500 let built = loft_skinned_along(model, sections, None, false, tolerance, tol)?;
3501 state_new_gaps(model, sections, &built.shape, tol)?;
3502 Ok(built)
3503}
3504
3505type SectionMotion<'a> = &'a dyn Fn(f64) -> OgeomResult<Transform>;
3509
3510fn loft_skinned_along(
3516 model: &mut Model,
3517 sections: &[Shape],
3518 along: Option<SectionMotion<'_>>,
3519 exact_ends: bool,
3520 tolerance: f64,
3521 tol: Tolerances,
3522) -> OgeomResult<Built> {
3523 if sections.len() < 2 {
3524 ogeom_bail!(Construction, "a loft needs at least two sections");
3525 }
3526 let to_point = model.kind_of(§ions[sections.len() - 1])? == ShapeType::Vertex;
3527 if sections.len() == 2
3531 && !to_point
3532 && along.is_none()
3533 && let Ok(built) = make_loft(model, §ions[0], §ions[1], tol)
3534 {
3535 return Ok(built);
3536 }
3537 if sections.len() > 2 && !to_point {
3538 if let Some(built) = coaxial_circles_loft(model, sections, tol)? {
3539 return Ok(built);
3540 }
3541 if let Some(built) = cornered_loft(model, sections, along, tolerance, tol)? {
3542 return Ok(built);
3543 }
3544 }
3545 let apex = match model.kind_of(§ions[sections.len() - 1])? {
3548 ShapeType::Vertex => {
3549 if sections.len() < 2 {
3550 ogeom_bail!(
3551 Construction,
3552 "a loft to a point needs a section to start from"
3553 );
3554 }
3555 let Some(data) = model
3556 .node(§ions[sections.len() - 1])
3557 .and_then(|n| n.data().as_vertex())
3558 else {
3559 ogeom_bail!(Construction, "the apex vertex holds no point");
3560 };
3561 Some(data.point)
3562 }
3563 _ => None,
3564 };
3565 let wires = §ions[..sections.len() - usize::from(apex.is_some())];
3566 let mut loops: Vec<Section> = Vec::with_capacity(sections.len() + 1);
3567 let mut cap_planes: Vec<Option<Plane>> = Vec::with_capacity(wires.len());
3568 for wire in wires {
3569 if model.kind_of(wire)? != ShapeType::Wire {
3570 ogeom_bail!(Construction, "a loft section is a wire");
3571 }
3572 if !ogeom_algo::is_wire_closed(model, wire, tol)? {
3573 ogeom_bail!(Construction, "a loft section must be closed");
3574 }
3575 cap_planes.push(ogeom_algo::find_plane(model, wire, tol)?);
3579 loops.push(Section::Loop(section_loop(model, wire, None, tol)?));
3580 }
3581 if along.is_none() && apex.is_none() {
3585 wound_alike(&mut loops);
3586 }
3587 let outward_at = |rows: &[Vec<Point>], planes: &[Option<Plane>], end: bool| -> EndCap {
3590 let (i, j) = if end {
3591 (rows.len() - 1, rows.len() - 2)
3592 } else {
3593 (0, 1)
3594 };
3595 let Some(plane) = &planes[i] else {
3596 return EndCap::Skinned;
3597 };
3598 let towards = rows[j][0] - rows[i][0];
3599 let n = plane.normal().vector();
3600 EndCap::Plane(if n.dot(towards) > 0.0 { -n } else { n })
3601 };
3602 let mut built = if let Some(apex) = apex {
3603 let sections_only = (loops.len() >= 2).then(|| loops.clone());
3611 if sections_only.is_none()
3612 && let Some(Section::Loop(ring)) = loops.first()
3613 {
3614 let half = Section::Halfway(ring.clone(), apex);
3619 loops.push(half);
3620 }
3621 loops.push(Section::Point(apex));
3622 let skin = Skin::sections(loops, false)?;
3623 let smooth = match outward_at(&skin.rows, &cap_planes, false) {
3624 EndCap::Plane(outward0) => {
3625 let unfolded_from = match sections_only {
3626 Some(_) => {
3627 let vs = skin.row_parameters()?;
3628 Some(vs[vs.len() - 2])
3629 }
3630 None => None,
3631 };
3632 skinned_solid_to_apex(model, &skin, outward0, unfolded_from, tolerance, tol)?
3633 }
3634 EndCap::Skinned | EndCap::Apex(_) => {
3635 ogeom_bail!(
3636 Construction,
3637 "a loft to a point starts from a planar section; a cap stands on it"
3638 );
3639 }
3640 };
3641 match (smooth, sections_only) {
3642 (Some(built), _) => built,
3643 (None, Some(sections)) => {
3644 let skin = Skin::sections(sections, false)?;
3645 let outward0 = outward_at(&skin.rows, &cap_planes, false);
3646 skinned_solid(model, &skin, (outward0, EndCap::Apex(apex)), tolerance, tol)?
3647 }
3648 (None, None) => {
3649 ogeom_bail!(Construction, "a cone over one section folded");
3650 }
3651 }
3652 } else {
3653 let skin = match along {
3654 Some(motion) => {
3655 let Some(Section::Loop(first)) = loops.first().cloned() else {
3656 ogeom_bail!(Construction, "a loft's first section is a loop");
3657 };
3658 let carried = if exact_ends {
3659 carried_section(model, sections, motion, tol)?
3660 } else {
3661 None
3662 };
3663 let mut skin = Skin::swept(
3664 move |f, s| {
3665 if s.fract() == 0.0 {
3666 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
3667 let i = s as usize;
3668 return Ok(loops[i].at(f));
3669 }
3670 Ok(motion(s)?.apply(first.at(f)))
3671 },
3672 fractions(AROUND_SECTION),
3673 (0, sections.len() - 1),
3674 true,
3675 )?;
3676 skin.carried = carried;
3677 skin
3678 }
3679 None => Skin::sections(loops, false)?,
3680 };
3681 let outward0 = outward_at(&skin.rows, &cap_planes, false);
3682 let outward1 = outward_at(&skin.rows, &cap_planes, true);
3683 skinned_solid(model, &skin, (outward0, outward1), tolerance, tol)?
3684 };
3685 for section in sections {
3686 built.history.generate(section, built.shape.clone());
3687 }
3688 Ok(built)
3689}
3690
3691fn carried_section<'a>(
3698 model: &Model,
3699 sections: &[Shape],
3700 motion: SectionMotion<'a>,
3701 tol: Tolerances,
3702) -> OgeomResult<Option<Carried<'a>>> {
3703 let mut forms: Vec<ogeom_geom::BSplineCurve> = Vec::with_capacity(sections.len());
3704 for wire in sections {
3705 if model.kind_of(wire)? != ShapeType::Wire {
3706 return Ok(None);
3707 }
3708 let edges = model.ordered_children_of(wire)?;
3709 let [edge] = edges.as_slice() else {
3710 return Ok(None);
3711 };
3712 let (curve, range) = spine_curve_of(model, edge)?;
3713 let curve = curve.transformed(&edge.transform(model.datums())?, tol)?;
3714 let Ok(form) = curve.to_bspline_over(range, tol) else {
3715 return Ok(None);
3716 };
3717 forms.push(form);
3718 }
3719 let first = &forms[0];
3720 let alike = forms.iter().all(|f| {
3721 f.knots().knots().len() == first.knots().knots().len()
3722 && f.knots()
3723 .knots()
3724 .iter()
3725 .zip(first.knots().knots())
3726 .all(|(a, b)| a.to_bits() == b.to_bits())
3727 && f.control_points()
3728 .iter()
3729 .zip(first.control_points())
3730 .all(|(a, b)| (a.weight - b.weight).abs() <= 1e-12 * b.weight)
3731 });
3732 if !alike {
3733 return Ok(None);
3734 }
3735 let control: Vec<Vec<Point>> = forms
3736 .iter()
3737 .map(|f| f.control_points().iter().map(|w| w.point()).collect())
3738 .collect();
3739 let n = control[0].len();
3740 let weights = first.control_points();
3743 let closed = (weights[0].weight - weights[n - 1].weight).abs() <= 1e-12 * weights[0].weight
3744 && control
3745 .iter()
3746 .all(|c| c[0].distance(c[n - 1]) <= tol.confusion());
3747 let mut curve = forms[0].clone();
3748 if closed {
3749 let mut points = curve.control_points().to_vec();
3750 points[n - 1] = points[0];
3751 curve = ogeom_geom::BSplineCurve::rational(curve.knots().clone(), points)?;
3752 }
3753 let steps = sections.len() - 1;
3754 #[allow(clippy::cast_precision_loss, reason = "section counts")]
3755 let vs: Vec<f64> = (0..=steps).map(|i| i as f64 / steps as f64).collect();
3756 let point = move |i: usize, v: f64| -> OgeomResult<Point> {
3757 let i = if closed && i + 1 == n { 0 } else { i };
3758 #[allow(clippy::cast_precision_loss, reason = "section counts")]
3759 let s = v * steps as f64;
3760 let whole = s.round();
3761 if (s - whole).abs() <= 1e-9 {
3762 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
3763 return Ok(control[whole as usize][i]);
3764 }
3765 Ok(motion(s)?.apply(control[0][i]))
3766 };
3767 Ok(Some(Carried {
3768 curve,
3769 control: Box::new(point),
3770 vs,
3771 closed,
3772 }))
3773}
3774
3775pub(crate) fn whole_ring(
3780 model: &mut Model,
3781 ring: &Shape,
3782 tol: Tolerances,
3783) -> OgeomResult<Option<Shape>> {
3784 let edges = model.ordered_children_of(ring)?;
3785 let [first, ..] = edges.as_slice() else {
3786 return Ok(None);
3787 };
3788 if edges.len() < 2 {
3789 return Ok(None);
3790 }
3791 let place = first.transform(model.datums())?;
3792 let (curve, _) = spine_curve_of(model, first)?;
3793 let mut ranges = Vec::with_capacity(edges.len());
3794 for edge in &edges {
3795 let (other, span) = spine_curve_of(model, edge)?;
3796 if other != curve
3797 || edge.transform(model.datums())? != place
3798 || edge.orientation() != first.orientation()
3799 {
3800 return Ok(None);
3801 }
3802 ranges.push((span.0.min(span.1), span.0.max(span.1)));
3803 }
3804 ranges.sort_by(|a, b| a.0.total_cmp(&b.0));
3805 if ranges
3806 .windows(2)
3807 .any(|w| (w[1].0 - w[0].1).abs() > tol.parametric())
3808 {
3809 return Ok(None);
3810 }
3811 let span = (ranges[0].0, ranges[ranges.len() - 1].1);
3812 if curve
3813 .point_at(span.0, tol)?
3814 .distance(curve.point_at(span.1, tol)?)
3815 > tol.confusion()
3816 {
3817 return Ok(None);
3818 }
3819 let edge = make_edge(model, curve.transformed(&place, tol)?, span, tol)?.shape;
3820 let edge = if first.orientation() == ogeom_topo::Orientation::Reversed {
3821 edge.reversed()
3822 } else {
3823 edge
3824 };
3825 let wire = ogeom_algo::make_wire(model, &[edge], tol)?.shape;
3826 Ok(Some(
3827 if ring.orientation() == ogeom_topo::Orientation::Reversed {
3828 wire.reversed()
3829 } else {
3830 wire
3831 },
3832 ))
3833}
3834
3835fn sample_wire(
3837 model: &Model,
3838 wire: &Shape,
3839 count: usize,
3840 tol: Tolerances,
3841) -> OgeomResult<Vec<Point>> {
3842 sample_wire_from(model, wire, count, None, tol)
3843}
3844
3845fn sample_wire_from(
3849 model: &Model,
3850 wire: &Shape,
3851 count: usize,
3852 start_hint: Option<Point>,
3853 tol: Tolerances,
3854) -> OgeomResult<Vec<Point>> {
3855 let arc = section_loop(model, wire, start_hint, tol)?;
3856 Ok((0..count)
3857 .map(|s| {
3858 #[allow(clippy::cast_precision_loss, reason = "a small count")]
3859 let f = s as f64 / count as f64;
3860 arc.at(f)
3861 })
3862 .collect())
3863}
3864
3865fn section_loop(
3868 model: &Model,
3869 wire: &Shape,
3870 start_hint: Option<Point>,
3871 tol: Tolerances,
3872) -> OgeomResult<ArcLoop> {
3873 let mut dense = dense_wire(model, wire, tol)?;
3874 if let Some(hint) = start_hint {
3875 let mut best = 0usize;
3876 let mut held = f64::INFINITY;
3877 for (i, p) in dense.iter().enumerate() {
3878 let d = p.distance(hint);
3879 if d < held {
3880 held = d;
3881 best = i;
3882 }
3883 }
3884 dense.rotate_left(best);
3885 }
3886 Ok(ArcLoop::new(dense))
3887}
3888
3889fn dense_wire(model: &Model, wire: &Shape, tol: Tolerances) -> OgeomResult<Vec<Point>> {
3892 let mut dense: Vec<Point> = Vec::new();
3893 for edge in explore(model, wire, Filter::OfType(ShapeType::Edge))? {
3894 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
3895 ogeom_bail!(Construction, "a section edge holds no data");
3896 };
3897 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
3898 ogeom_bail!(Construction, "a section edge has no curve");
3899 };
3900 let Some(geometry) = model.geometry().curve(*curve) else {
3901 ogeom_bail!(Dangling, "curve is not in this model");
3902 };
3903 let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
3904 let placement = edge.transform(model.datums())?;
3906 let at = |f: f64| -> OgeomResult<Point> {
3907 let t = if reversed {
3908 range.1 - (range.1 - range.0) * f
3909 } else {
3910 range.0 + (range.1 - range.0) * f
3911 };
3912 Ok(placement.apply(geometry.point_at(t, tol)?))
3913 };
3914 const FIRST: u32 = 64;
3922 let allowed = tol.confusion() * 10.0;
3923 for i in 0..FIRST {
3924 let (f0, f1) = (
3925 f64::from(i) / f64::from(FIRST),
3926 f64::from(i + 1) / f64::from(FIRST),
3927 );
3928 chords_within(&at, (f0, at(f0)?), (f1, at(f1)?), allowed, 0, &mut dense)?;
3929 }
3930 }
3931 if dense.is_empty() {
3932 ogeom_bail!(Construction, "a section has no edges");
3933 }
3934 Ok(dense)
3935}
3936
3937pub(super) fn chords_within(
3942 at: &impl Fn(f64) -> OgeomResult<Point>,
3943 a: (f64, Point),
3944 b: (f64, Point),
3945 allowed: f64,
3946 depth: u32,
3947 out: &mut Vec<Point>,
3948) -> OgeomResult<()> {
3949 const DEEPEST: u32 = 20;
3952 let chord = b.1 - a.1;
3953 let length = chord.dot(chord);
3954 let off = |p: Point| {
3955 let t = if length > 0.0 {
3956 ((p - a.1).dot(chord) / length).clamp(0.0, 1.0)
3957 } else {
3958 0.0
3959 };
3960 p.distance(a.1 + chord * t)
3961 };
3962 let mid = f64::midpoint(a.0, b.0);
3963 let m = at(mid)?;
3964 let sags = depth < DEEPEST
3965 && (off(m) > allowed
3966 || off(at(f64::midpoint(a.0, mid))?) > allowed
3967 || off(at(f64::midpoint(mid, b.0))?) > allowed);
3968 if sags {
3969 chords_within(at, a, (mid, m), allowed, depth + 1, out)?;
3970 chords_within(at, (mid, m), b, allowed, depth + 1, out)
3971 } else {
3972 out.push(a.1);
3973 Ok(())
3974 }
3975}
3976
3977fn matched_loop(dense: &[Point], count: usize, previous: &[Point]) -> ArcLoop {
3989 let n = dense.len();
3990 let ways = [
3991 ArcLoop::new(dense.to_vec()),
3992 ArcLoop::new(dense.iter().rev().copied().collect()),
3993 ];
3994 #[allow(clippy::cast_precision_loss, reason = "a sample count")]
3995 fn samples(arc: &ArcLoop, start: usize, count: usize) -> impl Iterator<Item = Point> + '_ {
3996 let offset = arc.lengths[start] / arc.total;
3997 (0..count).map(move |k| {
3998 let f = offset + k as f64 / count as f64;
3999 arc.at(if f >= 1.0 { f - 1.0 } else { f })
4000 })
4001 }
4002 let cost = |arc: &ArcLoop, start: usize| -> f64 {
4003 samples(arc, start, count)
4004 .zip(previous)
4005 .map(|(p, q)| (p - *q).dot(p - *q))
4006 .sum()
4007 };
4008 let scale: f64 = previous
4009 .iter()
4010 .copied()
4011 .chain(samples(&ways[0], 0, count))
4012 .map(|p| (p - Point::ORIGIN).dot(p - Point::ORIGIN))
4013 .sum();
4014 let slack = scale * 1e-12;
4015 let coarse = 8 * count.max(1);
4016 let mut held = cost(&ways[0], 0);
4017 let mut best = (0, 0);
4018 for (w, arc) in ways.iter().enumerate() {
4019 let mut tried = vec![n <= 2 * coarse; n];
4020 if n > 2 * coarse {
4021 let picks: Vec<usize> = (0..coarse).map(|j| j * n / coarse).collect();
4022 let costs: Vec<f64> = picks.iter().map(|&i| cost(arc, i)).collect();
4023 for j in 0..coarse {
4024 let (before, after) = ((j + coarse - 1) % coarse, (j + 1) % coarse);
4025 if costs[j] <= costs[before] && costs[j] <= costs[after] {
4026 let (from, to) = (picks[before], picks[after]);
4027 let mut i = from;
4028 loop {
4029 tried[i] = true;
4030 if i == to {
4031 break;
4032 }
4033 i = (i + 1) % n;
4034 }
4035 }
4036 }
4037 }
4038 for start in (0..n).filter(|&i| tried[i]) {
4039 let c = cost(arc, start);
4040 if c < held - slack {
4041 held = c;
4042 best = (w, start);
4043 }
4044 }
4045 }
4046 let [forward, backward] = ways;
4047 let (way, start) = best;
4048 let arc = if way == 0 { forward } else { backward };
4049 if start == 0 {
4050 return arc;
4051 }
4052 let mut turned = arc.dense;
4053 turned.rotate_left(start);
4054 ArcLoop::new(turned)
4055}
4056
4057pub fn make_pipe_skinned(
4076 model: &mut Model,
4077 spine: &Shape,
4078 radius: f64,
4079 tolerance: f64,
4080 tol: Tolerances,
4081) -> OgeomResult<Built> {
4082 if !radius.is_finite() || radius <= tol.confusion() {
4083 ogeom_bail!(Construction, "a pipe of radius {radius} holds nothing");
4084 }
4085 let placed = placed_path(model, spine, tol)?;
4087 if placed != *spine {
4088 let mut built = make_pipe_skinned(model, &placed, radius, tolerance, tol)?;
4089 built.history.generate(spine, built.shape.clone());
4090 return Ok(built);
4091 }
4092 if model.kind_of(spine)? == ShapeType::Wire {
4093 let edges = model.ordered_children_of(spine)?;
4094 if let [edge] = edges.as_slice() {
4095 let mut built = make_pipe_skinned(model, edge, radius, tolerance, tol)?;
4096 built.history.generate(spine, built.shape.clone());
4097 return Ok(built);
4098 }
4099 let disc = disc_at_start(model, spine, &edges, radius, tol)?;
4100 let built = make_pipe_shell(model, &disc, spine, false, tolerance, tol)?;
4101 let mut history = History::new();
4102 history.generate(spine, built.shape.clone());
4103 return Ok(Built::new(built.shape, history));
4104 }
4105 let (curve, range) = {
4106 let Some(data) = model.node(spine).and_then(|n| n.data().as_edge()) else {
4107 ogeom_bail!(Construction, "a pipe runs along an edge");
4108 };
4109 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
4110 ogeom_bail!(Construction, "the spine has no curve");
4111 };
4112 let Some(geometry) = model.geometry().curve(*curve) else {
4113 ogeom_bail!(Dangling, "curve is not in this model");
4114 };
4115 (geometry.clone(), *range)
4116 };
4117 const STATIONS: usize = 33;
4121 const FRAMES: usize = 64 * (STATIONS - 1) + 1;
4122 const AROUND: usize = 40;
4123 let mut stations: Vec<SpineStation> = Vec::with_capacity(FRAMES);
4124 for i in 0..FRAMES {
4125 #[allow(clippy::cast_precision_loss)]
4126 let t = range.0 + (range.1 - range.0) * (i as f64) / ((FRAMES - 1) as f64);
4127 let p = curve.point_at(t, tol)?;
4128 let d = curve.d1_at(t, tol)?;
4129 let m = d.magnitude();
4130 if m <= tol.confusion() {
4131 ogeom_bail!(Construction, "the spine is degenerate at {t}");
4132 }
4133 stations.push(SpineStation {
4134 at: p,
4135 tangent: d / m,
4136 edge: 0,
4137 t,
4138 });
4139 }
4140 let normals = rmf_normals(&stations);
4141 let tube = |u: f64, t: f64| -> OgeomResult<Point> {
4147 let k = stations.partition_point(|s| s.t <= t).clamp(1, FRAMES) - 1;
4148 let p = curve.point_at(t, tol)?;
4149 let d = curve.d1_at(t, tol)?;
4150 let m = d.magnitude();
4151 if m <= tol.confusion() {
4152 ogeom_bail!(Construction, "the spine is degenerate at {t}");
4153 }
4154 let tangent = d / m;
4155 let x = rmf_step(stations[k].at, stations[k].tangent, normals[k], p, tangent);
4156 let y = tangent.cross(x);
4157 let ang = core::f64::consts::TAU * u;
4158 Ok(p + (x * ang.cos() + y * ang.sin()) * radius)
4159 };
4160 let fraction = |i: usize, n: usize| -> f64 {
4161 #[allow(clippy::cast_precision_loss, reason = "a small count")]
4162 let f = i as f64 / n as f64;
4163 f
4164 };
4165 let us: Vec<f64> = (0..=AROUND).map(|a| fraction(a, AROUND)).collect();
4166 let vs: Vec<f64> = stations.iter().step_by(64).map(|s| s.t).collect();
4167 let rows = vs
4168 .iter()
4169 .map(|&t| us[..AROUND].iter().map(|&u| tube(u, t)).collect())
4170 .collect::<OgeomResult<Vec<Vec<Point>>>>()?;
4171 let skin = Skin {
4172 rows,
4173 round: true,
4174 by_spacing: false,
4175 traced: Some(Traced {
4176 point: Box::new(move |u, t| tube(if u >= 1.0 { 0.0 } else { u }, t)),
4179 sampling: ogeom_geom::fit::Sampling {
4180 us,
4181 vs,
4182 between: (true, true),
4183 closed_v: false,
4184 most: 512,
4185 },
4186 }),
4187 carried: None,
4188 };
4189 let mut built = skinned_solid(
4190 model,
4191 &skin,
4192 (
4193 EndCap::Plane(-stations[0].tangent),
4194 EndCap::Plane(stations[FRAMES - 1].tangent),
4195 ),
4196 tolerance,
4197 tol,
4198 )?;
4199 built.history.generate(spine, built.shape.clone());
4200 state_new_gaps(model, std::slice::from_ref(spine), &built.shape, tol)?;
4201 Ok(built)
4202}
4203
4204#[derive(Clone, Copy)]
4206pub(crate) struct SpineStation {
4207 pub(crate) at: Point,
4208 pub(crate) tangent: Vector,
4210 pub(crate) edge: usize,
4212 pub(crate) t: f64,
4214}
4215
4216#[allow(clippy::too_many_arguments, reason = "one frame, spelled out")]
4223fn closed_loop_shell(
4224 model: &mut Model,
4225 profile_loop: &Shape,
4226 edges: &[Shape],
4227 smooth: bool,
4228 stations: &[SpineStation],
4229 carried: &dyn Fn(f64, (f64, f64)) -> OgeomResult<Point>,
4230 frame0: (Point, Vector),
4231 tolerance: f64,
4232 tol: Tolerances,
4233) -> OgeomResult<Shape> {
4234 const AROUND: usize = 40;
4235 let (origin, x0) = frame0;
4236 let t0 = stations[0].tangent;
4237 let y0 = t0.cross(x0);
4238 let home = stations.len() - 1;
4241 if !smooth {
4242 const ALONG_EDGE: usize = 8;
4246 let mid_station = stations[stations.len() / 2].at;
4250 let mut faces = Vec::with_capacity(edges.len());
4251 let mut rails: Vec<Option<Shape>> = vec![None; edges.len()];
4254 for (index, edge) in edges.iter().enumerate() {
4255 let (curve, range) = spine_curve_of(model, edge)?;
4256 let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
4257 let skin = Skin::swept(
4261 |f, s| {
4262 let t = if reversed {
4263 range.1 - (range.1 - range.0) * f
4264 } else {
4265 range.0 + (range.1 - range.0) * f
4266 };
4267 let p = curve.point_at(t, tol)?;
4268 carried(s, ((p - origin).dot(x0), (p - origin).dot(y0)))
4269 },
4270 fractions(ALONG_EDGE),
4271 (0, home),
4272 false,
4273 )?;
4274 let next = (index + 1) % edges.len();
4275 let shared = [rails[index].clone(), rails[next].clone()];
4276 let (face, rail0, rail1) = skinned_ring_strip(
4277 model,
4278 &skin,
4279 mid_station,
4280 [shared[0].as_ref(), shared[1].as_ref()],
4281 tolerance,
4282 tol,
4283 )?;
4284 rails[index] = Some(rail0);
4285 rails[next] = Some(rail1);
4286 faces.push(face);
4287 }
4288 let sewn = sew(model, &faces, tol)?;
4289 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
4290 if std::env::var_os("OGEOM_DEBUG_RING").is_some() {
4291 use ogeom_geom::Curve3d as _;
4292 eprintln!(
4293 "RING: {} shells from {} strips",
4294 sewn.shells.len(),
4295 faces.len()
4296 );
4297 for shell in &sewn.shells {
4298 for edge in ogeom_topo::explore_unique(model, shell, ShapeType::Edge)? {
4299 let mut uses = 0;
4300 for f in explore(model, shell, Filter::OfType(ShapeType::Face))? {
4301 for w in model.children_of(&f)? {
4302 for e in model.children_of(&w)? {
4303 if e.node() == edge.node() {
4304 uses += 1;
4305 }
4306 }
4307 }
4308 }
4309 if uses == 1
4310 && let Some(d) = model.node(&edge).and_then(|n| n.data().as_edge())
4311 && let Some(ogeom_topo::EdgeRepr::Curve3d { curve, range, .. }) =
4312 d.curve3d()
4313 && let Some(g) = model.geometry().curve(*curve)
4314 {
4315 eprintln!(
4316 "RING open edge tol {:.2e}: {:?} -> {:?}",
4317 d.tolerance.get(),
4318 g.point_at(range.0, tol)?,
4319 g.point_at(range.1, tol)?
4320 );
4321 }
4322 }
4323 }
4324 }
4325 ogeom_bail!(Construction, "the faceted ring did not close");
4326 }
4327 return Ok(sewn.shells[0].clone());
4328 }
4329 let around = section_loop(model, profile_loop, None, tol)?;
4332 let skin = Skin::swept(
4333 |f, s| {
4334 let p = around.at(f);
4335 carried(s, ((p - origin).dot(x0), (p - origin).dot(y0)))
4336 },
4337 fractions(AROUND),
4338 (0, home),
4339 true,
4340 )?;
4341 closed_skinned_shell(model, &skin, tolerance, tol)
4342}
4343
4344#[allow(clippy::too_many_arguments, reason = "one frame, spelled out")]
4362fn turned_loop_shell(
4363 model: &mut Model,
4364 edges: &[Shape],
4365 smooth: bool,
4366 stations: &[SpineStation],
4367 carried: &dyn Fn(f64, (f64, f64)) -> OgeomResult<Point>,
4368 share: &dyn Fn(f64) -> OgeomResult<f64>,
4369 frame0: (Point, Vector),
4370 tolerance: f64,
4371 tol: Tolerances,
4372) -> OgeomResult<Shape> {
4373 const ALONG_EDGE: usize = 8;
4374 const AROUND: usize = 40;
4375 const CHECKED: usize = 32;
4377 let refused = || -> OgeomResult<Shape> {
4378 ogeom_bail!(
4379 Construction,
4380 "a twist round a closed spine is a whole number of turns, or a part of one \
4381 the section's symmetry brings home onto itself; this section comes home \
4382 turned off itself"
4383 )
4384 };
4385 let (origin, x0) = frame0;
4386 let y0 = stations[0].tangent.cross(x0);
4387 let flat = |p: Point| ((p - origin).dot(x0), (p - origin).dot(y0));
4388 let home = stations.len() - 1;
4389 #[allow(clippy::cast_precision_loss, reason = "a station count")]
4390 let end = home as f64;
4391 let near = tol.confusion();
4392 let mut walked: Vec<(Curve, (f64, f64), bool)> = Vec::with_capacity(edges.len());
4394 for edge in edges {
4395 let (curve, range) = spine_curve_of(model, edge)?;
4396 let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
4397 walked.push((curve, range, reversed));
4398 }
4399 let at = |e: usize, f: f64| -> OgeomResult<Point> {
4400 let (curve, range, reversed) = &walked[e];
4401 let t = if *reversed {
4402 range.1 - (range.1 - range.0) * f
4403 } else {
4404 range.0 + (range.1 - range.0) * f
4405 };
4406 curve.point_at(t, tol)
4407 };
4408 let mid = stations[home / 2].at;
4409 #[allow(clippy::cast_precision_loss, reason = "a small count")]
4410 let checked = |k: usize| k as f64 / CHECKED as f64;
4411
4412 if smooth {
4413 let (curve, range, reversed) = &walked[0];
4415 let span = range.1 - range.0;
4416 let target = carried(end, flat(at(0, 0.0)?))?;
4417 let found = ogeom_algo::project_on_curve(curve, target, 4 * CHECKED, tol)?;
4418 if found.distance > near {
4419 return refused();
4420 }
4421 let slide = if *reversed {
4422 (range.1 - found.parameter) / span
4423 } else {
4424 (found.parameter - range.0) / span
4425 }
4426 .rem_euclid(1.0);
4427 let round = |e: usize, f: f64| at(e, f.rem_euclid(1.0));
4428 for k in 0..CHECKED {
4429 let f = checked(k);
4430 if carried(end, flat(round(0, f)?))?.distance(round(0, f + slide)?) > near {
4431 return refused();
4432 }
4433 }
4434 let skin = Skin::swept(
4435 |f, s| carried(s, flat(round(0, f - slide * share(s)?)?)),
4436 fractions(AROUND),
4437 (0, home),
4438 true,
4439 )?;
4440 return closed_skinned_shell(model, &skin, tolerance, tol);
4441 }
4442
4443 let n = edges.len();
4446 let starts: Vec<Point> = (0..n).map(|e| at(e, 0.0)).collect::<OgeomResult<_>>()?;
4447 let first = carried(end, flat(starts[0]))?;
4448 let Some(onto) = (0..n).find(|&j| starts[j].distance(first) <= near) else {
4449 return refused();
4450 };
4451 let lands: Vec<usize> = (0..n).map(|i| (i + onto) % n).collect();
4452 for i in 0..n {
4453 if carried(end, flat(starts[i]))?.distance(starts[lands[i]]) > near {
4454 return refused();
4455 }
4456 let (curve, range, _) = &walked[lands[i]];
4457 let (lo, hi) = (range.0.min(range.1), range.0.max(range.1));
4458 for k in 0..=CHECKED {
4459 let image = carried(end, flat(at(i, checked(k))?))?;
4460 let found = ogeom_algo::project_on_curve(curve, image, CHECKED, tol)?;
4461 let inside = found.parameter >= lo - tol.parametric()
4462 && found.parameter <= hi + tol.parametric();
4463 if !inside || found.distance > near {
4464 return refused();
4465 }
4466 }
4467 }
4468 let corners: Vec<Shape> = starts
4469 .iter()
4470 .map(|p| -> OgeomResult<Shape> {
4471 Ok(model.add_vertex(VertexData::new(carried(0.0, flat(*p))?)))
4472 })
4473 .collect::<OgeomResult<_>>()?;
4474 let mut bottoms: Vec<Option<Shape>> = vec![None; n];
4478 let mut rails: Vec<Option<Shape>> = vec![None; n];
4479 let mut faces = Vec::with_capacity(n);
4480 for i in 0..n {
4481 let next = (i + 1) % n;
4482 let (j, j_next) = (lands[i], lands[next]);
4483 let skin = Skin::swept(
4484 |f, s| carried(s, flat(at(i, f)?)),
4485 fractions(ALONG_EDGE),
4486 (0, home),
4487 false,
4488 )?;
4489 let at_corner = [i, next, j, j_next];
4494 let twice = |c: usize| at_corner.iter().filter(|k| **k == c).count() > 1;
4495 let shared = [
4496 (bottoms[i].as_ref(), i),
4497 (bottoms[j].as_ref(), j),
4498 (rails[i].as_ref(), i),
4499 (rails[next].as_ref(), next),
4500 ]
4501 .map(|(edge, start)| edge.filter(|_| !twice(start)));
4502 let strip = skinned_strip(
4503 model,
4504 &skin,
4505 (&corners[i], &corners[next], &corners[j], &corners[j_next]),
4506 shared,
4507 mid,
4508 false,
4509 tolerance,
4510 tol,
4511 )?;
4512 bottoms[i].get_or_insert(strip.bottom);
4513 bottoms[j].get_or_insert(strip.top);
4514 rails[i].get_or_insert(strip.rail0);
4515 rails[next].get_or_insert(strip.rail1);
4516 faces.push(strip.face);
4517 }
4518 let sewn = sew(model, &faces, tol)?;
4519 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
4520 ogeom_bail!(Construction, "the turned faceted ring did not close");
4521 }
4522 Ok(sewn.shells[0].clone())
4523}
4524
4525fn rmf_normals(stations: &[SpineStation]) -> Vec<Vector> {
4530 let mut normals: Vec<Vector> = Vec::with_capacity(stations.len());
4531 let t0 = stations[0].tangent;
4532 let seed = if t0.cross(ogeom_math::Vector::Z).magnitude() > 0.5 {
4533 ogeom_math::Vector::Z
4534 } else {
4535 ogeom_math::Vector::X
4536 };
4537 let n0 = {
4538 let v = seed - t0 * seed.dot(t0);
4539 v / v.magnitude()
4540 };
4541 normals.push(n0);
4542 for i in 1..stations.len() {
4543 let (p0, t0) = (stations[i - 1].at, stations[i - 1].tangent);
4544 let (p1, t1) = (stations[i].at, stations[i].tangent);
4545 let n = normals[i - 1];
4546 let v1 = p1 - p0;
4547 let c1 = v1.dot(v1);
4548 if c1 <= 1e-20 {
4549 let bisector = t0 + t1;
4559 let m = bisector.magnitude();
4560 if m <= 1e-12 {
4561 normals.push(n);
4562 continue;
4563 }
4564 let b = bisector / m;
4565 normals.push(n - b * (2.0 * n.dot(b)));
4566 continue;
4567 }
4568 normals.push(rmf_step(p0, t0, n, p1, t1));
4569 }
4570 normals
4571}
4572
4573fn rmf_step(p0: Point, t0: Vector, n0: Vector, p1: Point, t1: Vector) -> Vector {
4576 let v1 = p1 - p0;
4577 let c1 = v1.dot(v1);
4578 if c1 <= 1e-20 {
4579 return n0;
4580 }
4581 let nl = n0 - v1 * (2.0 / c1 * v1.dot(n0));
4582 let tl = t0 - v1 * (2.0 / c1 * v1.dot(t0));
4583 let v2 = t1 - tl;
4584 let c2 = v2.dot(v2);
4585 let next = if c2 > 1e-20 {
4586 nl - v2 * (2.0 / c2 * v2.dot(nl))
4587 } else {
4588 nl
4589 };
4590 next / next.magnitude()
4591}
4592
4593struct SpineWalk<'a> {
4600 curves: Vec<WalkCurve>,
4601 stations: &'a [SpineStation],
4602 normals: &'a [Vector],
4603}
4604
4605type WalkCurve = (ogeom_geom::Curve, (f64, f64), bool);
4607
4608fn walk_curves(model: &Model, spine: &Shape) -> OgeomResult<Vec<WalkCurve>> {
4610 let edges: Vec<Shape> = match model.kind_of(spine)? {
4611 ShapeType::Edge => vec![spine.clone()],
4612 _ => model.ordered_children_of(spine)?,
4613 };
4614 let mut out = Vec::with_capacity(edges.len());
4615 for edge in &edges {
4616 let (curve, range) = spine_curve_of(model, edge)?;
4617 out.push((
4618 curve,
4619 range,
4620 edge.orientation() == ogeom_topo::Orientation::Reversed,
4621 ));
4622 }
4623 Ok(out)
4624}
4625
4626struct CornerJoin {
4630 at: Point,
4631 s1: f64,
4632 s2: f64,
4633 gap: f64,
4634}
4635
4636impl SpineWalk<'_> {
4637 fn place_between(&self, j: usize, f: f64) -> (usize, f64) {
4641 let st = self.stations;
4642 let edge = st[j + 1].edge;
4643 let (_, range, reversed) = &self.curves[edge];
4644 let t_from = if st[j].edge == edge {
4645 st[j].t
4646 } else if *reversed {
4647 range.1
4648 } else {
4649 range.0
4650 };
4651 (edge, t_from + (st[j + 1].t - t_from) * f)
4652 }
4653
4654 fn frame_at(
4657 &self,
4658 s: f64,
4659 (rs, re): (usize, usize),
4660 tol: Tolerances,
4661 ) -> OgeomResult<(Point, Vector, Vector)> {
4662 let st = self.stations;
4663 let at = |i: usize| (st[i].at, st[i].tangent, self.normals[i]);
4664 #[allow(clippy::cast_precision_loss)]
4665 let (rsf, ref_) = (rs as f64, re as f64);
4666 if s <= rsf {
4667 let (p, t, n) = at(rs);
4668 let h = st[rs].at.distance(st[(rs + 1).min(re)].at);
4669 return Ok((p + t * ((s - rsf) * h), t, n));
4670 }
4671 if s >= ref_ {
4672 let (p, t, n) = at(re);
4673 let h = st[re].at.distance(st[re.saturating_sub(1).max(rs)].at);
4674 return Ok((p + t * ((s - ref_) * h), t, n));
4675 }
4676 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
4677 let j = s.floor() as usize;
4678 #[allow(clippy::cast_precision_loss)]
4679 let f = s - j as f64;
4680 if f <= 0.0 {
4681 return Ok(at(j));
4682 }
4683 let (edge, t) = self.place_between(j, f);
4684 let (curve, _, reversed) = &self.curves[edge];
4685 let p = curve.point_at(t, tol)?;
4686 let d = curve.d1_at(t, tol)?;
4687 let m = d.magnitude();
4688 if m <= tol.confusion() {
4689 ogeom_bail!(Construction, "the spine is degenerate at {t}");
4690 }
4691 let tangent = if *reversed { -(d / m) } else { d / m };
4692 let n = rmf_step(st[j].at, st[j].tangent, self.normals[j], p, tangent);
4693 let carried = rmf_step(
4698 st[j].at,
4699 st[j].tangent,
4700 self.normals[j],
4701 st[j + 1].at,
4702 st[j + 1].tangent,
4703 );
4704 let ahead = self.normals[j + 1];
4705 let turn = carried
4706 .cross(ahead)
4707 .dot(st[j + 1].tangent)
4708 .atan2(carried.dot(ahead));
4709 if turn == 0.0 {
4710 return Ok((p, tangent, n));
4711 }
4712 let phi = turn * f;
4713 let n = n * phi.cos() + tangent.cross(n) * phi.sin();
4714 let n = n - tangent * n.dot(tangent);
4715 Ok((p, tangent, n / n.magnitude()))
4716 }
4717
4718 fn generator(
4720 &self,
4721 s: f64,
4722 run: (usize, usize),
4723 (a, b): (f64, f64),
4724 tol: Tolerances,
4725 ) -> OgeomResult<Point> {
4726 let (p, t, x) = self.frame_at(s, run, tol)?;
4727 let y = t.cross(x);
4728 Ok(p + x * a + y * b)
4729 }
4730
4731 fn join(
4735 &self,
4736 before: (usize, usize),
4737 after: (usize, usize),
4738 ab: (f64, f64),
4739 tol: Tolerances,
4740 ) -> OgeomResult<CornerJoin> {
4741 const STEP: f64 = 1e-4;
4742 #[allow(clippy::cast_precision_loss)]
4743 let (mut s1, mut s2) = (before.1 as f64, after.0 as f64);
4744 for _ in 0..60 {
4745 let g1 = self.generator(s1, before, ab, tol)?;
4746 let g2 = self.generator(s2, after, ab, tol)?;
4747 let f = g1 - g2;
4748 let d1 = (self.generator(s1 + STEP, before, ab, tol)?
4749 - self.generator(s1 - STEP, before, ab, tol)?)
4750 / (2.0 * STEP);
4751 let d2 = (self.generator(s2 + STEP, after, ab, tol)?
4752 - self.generator(s2 - STEP, after, ab, tol)?)
4753 / (2.0 * STEP);
4754 let (a11, a12, a22) = (d1.dot(d1), -d1.dot(d2), d2.dot(d2));
4755 let (b1, b2) = (-f.dot(d1), f.dot(d2));
4756 let det = a11 * a22 - a12 * a12;
4757 if det.abs() <= 1e-30 {
4758 break;
4759 }
4760 let e1 = (b1 * a22 - a12 * b2) / det;
4761 let e2 = (a11 * b2 - a12 * b1) / det;
4762 s1 += e1;
4763 s2 += e2;
4764 if e1.abs().max(e2.abs()) <= 1e-12 {
4765 break;
4766 }
4767 }
4768 let g1 = self.generator(s1, before, ab, tol)?;
4769 let g2 = self.generator(s2, after, ab, tol)?;
4770 Ok(CornerJoin {
4771 at: g1.midpoint(g2),
4772 s1,
4773 s2,
4774 gap: g1.distance(g2),
4775 })
4776 }
4777}
4778
4779#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
4807pub fn make_helical_sweep(
4808 model: &mut Model,
4809 profile: &Shape,
4810 axis: ogeom_math::Axis,
4811 pitch: f64,
4812 turns: f64,
4813 left_handed: bool,
4814 taper_per_turn: f64,
4815 tol: Tolerances,
4816) -> OgeomResult<Built> {
4817 if !(pitch.is_finite() && pitch >= 0.0) || !(turns.is_finite() && turns > 0.0) {
4818 ogeom_bail!(
4819 Construction,
4820 "a helical sweep needs a pitch of zero or more and a positive turn count; \
4821 got {pitch} and {turns}"
4822 );
4823 }
4824 if !taper_per_turn.is_finite() {
4825 ogeom_bail!(Construction, "a taper of {taper_per_turn} is not a length");
4826 }
4827 let flat = pitch <= tol.confusion();
4828 if flat && taper_per_turn.abs() <= tol.confusion() {
4829 ogeom_bail!(
4830 Construction,
4831 "a helical sweep with no pitch and no taper turns the profile onto itself"
4832 );
4833 }
4834 if model.kind_of(profile)? != ShapeType::Face {
4835 ogeom_bail!(Construction, "a helical sweep sweeps a planar face");
4836 }
4837 let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
4838 ogeom_bail!(Construction, "a helical sweep sweeps a planar face");
4839 };
4840 let z = axis.direction.vector();
4841 let n = plane.normal().vector();
4842 let holds_axis = n.dot(z).abs() <= tol.angular()
4843 && plane.distance_to(axis.location) <= tol.confusion() * 100.0;
4844 let level = (n.dot(z).abs() - 1.0).abs() <= tol.angular();
4845 if taper_per_turn.abs() > tol.confusion() && !holds_axis && !level {
4849 ogeom_bail!(
4850 Construction,
4851 "a tapered helical sweep carries a profile in a plane through the \
4852 axis or square to it; an oblique profile would leave its plane"
4853 );
4854 }
4855 if !holds_axis && n.dot(z).abs() > tol.angular() {
4859 let reach = (plane.origin() - axis.location).dot(n) / z.dot(n);
4860 let pierce = axis.location + z * reach;
4861 let frame = plane.frame();
4862 let flat = |p: Point| {
4863 let l = frame.to_local(p);
4864 Point2::new(l.x, l.y)
4865 };
4866 let at = flat(pierce);
4867 let mut inside = false;
4868 let mut touches = false;
4869 for wire in explore(model, profile, Filter::OfType(ShapeType::Wire))? {
4870 let ring: Vec<Point2> = sample_wire(model, &wire, 256, tol)?
4871 .into_iter()
4872 .map(flat)
4873 .collect();
4874 for (k, a) in ring.iter().enumerate() {
4875 let b = ring[(k + 1) % ring.len()];
4876 let (ab, ap) = (b - *a, at - *a);
4877 let t = (ap.dot(ab) / ab.dot(ab).max(f64::MIN_POSITIVE)).clamp(0.0, 1.0);
4878 touches |= (*a + ab * t).distance(at) <= tol.confusion() * 100.0;
4879 if (a.y > at.y) != (b.y > at.y) && at.x < a.x + (at.y - a.y) / (b.y - a.y) * ab.x {
4880 inside = !inside;
4881 }
4882 }
4883 }
4884 if inside || touches {
4885 ogeom_bail!(
4886 Construction,
4887 "the profile meets the axis, where a helical sweep has no helix \
4888 to follow"
4889 );
4890 }
4891 }
4892 let total = core::f64::consts::TAU * turns;
4893 let sense = if left_handed { -1.0 } else { 1.0 };
4894 let travel = |p: Point| -> Vector {
4897 let foot = axis.project(p);
4898 let out = p - foot;
4899 let rho = out.magnitude().max(f64::MIN_POSITIVE);
4900 let radial = out / rho;
4901 (z * pitch + radial * taper_per_turn) / core::f64::consts::TAU
4902 + z.cross(radial) * (rho * sense)
4903 };
4904 let screw = |p: Point, theta: f64| -> OgeomResult<Point> {
4906 let foot = axis.project(p);
4907 let out = p - foot;
4908 let rho = out.magnitude();
4909 let grown = rho + taper_per_turn * theta / core::f64::consts::TAU;
4910 if rho <= tol.confusion() || grown <= tol.confusion() {
4911 ogeom_bail!(
4912 Construction,
4913 "the profile reaches the axis, where a helical sweep has no \
4914 helix to follow"
4915 );
4916 }
4917 let radial = out / rho;
4918 let across = z.cross(radial);
4919 let (sin, cos) = (sense * theta).sin_cos();
4920 let turned = radial * cos + across * sin;
4921 Ok(foot + z * (pitch * theta / core::f64::consts::TAU) + turned * grown)
4922 };
4923
4924 let loops = explore(model, profile, Filter::OfType(ShapeType::Wire))?;
4926 if loops.is_empty() {
4927 ogeom_bail!(Construction, "the profile has no loop to sweep");
4928 }
4929 {
4933 let (mut ahead, mut behind) = (0.0_f64, 0.0_f64);
4934 for wire in &loops {
4935 for p in sample_wire(model, wire, 64, tol)? {
4936 let t = travel(p);
4937 let across = t.dot(n) / t.magnitude().max(f64::MIN_POSITIVE);
4938 ahead = ahead.max(across);
4939 behind = behind.max(-across);
4940 }
4941 }
4942 if ahead > tol.angular() && behind > tol.angular() {
4943 ogeom_bail!(
4944 Construction,
4945 "the screw carries part of the profile forward through its plane \
4946 and part back; the sweep runs into itself"
4947 );
4948 }
4949 if ahead <= tol.angular() && behind <= tol.angular() {
4950 ogeom_bail!(
4951 Construction,
4952 "the screw carries the profile along its own plane; it sweeps \
4953 no volume"
4954 );
4955 }
4956 }
4957 if turns > 1.0 {
4960 let mut low = f64::INFINITY;
4961 let mut high = f64::NEG_INFINITY;
4962 for wire in &loops {
4963 for p in sample_wire(model, wire, 64, tol)? {
4964 let h = if flat {
4965 p.distance(axis.project(p))
4966 } else {
4967 (p - axis.location).dot(z)
4968 };
4969 low = low.min(h);
4970 high = high.max(h);
4971 }
4972 }
4973 let (clearance, across) = if flat {
4974 (taper_per_turn.abs(), "away from the axis")
4975 } else {
4976 (pitch, "along the axis")
4977 };
4978 if high - low >= clearance - tol.confusion() {
4979 ogeom_bail!(
4980 Construction,
4981 "the profile spans {} {across}, not less than the turn's advance \
4982 {clearance}; a turn on it meets itself",
4983 high - low
4984 );
4985 }
4986 }
4987 let tolerance = tol.confusion() * 100.0;
4988 let quarter = core::f64::consts::FRAC_PI_2;
4996 let mut borders = vec![0.0];
4997 let mut next_border = quarter / 2.0;
4998 while next_border < total - quarter * 1e-3 {
4999 borders.push(next_border);
5000 next_border += quarter;
5001 }
5002 borders.push(total);
5003 let segments = borders.len() - 1;
5004 const PER_SEGMENT: usize = 48;
5007 #[allow(clippy::cast_precision_loss)]
5010 let theta_at = |seg: usize, s: f64| {
5011 borders[seg] + (borders[seg + 1] - borders[seg]) * s / (PER_SEGMENT as f64)
5012 };
5013
5014 let mut faces: Vec<Shape> = Vec::new();
5015 let mut cap_loops: [Vec<Vec<Shape>>; 2] = [Vec::new(), Vec::new()];
5016 for (li, wire) in loops.iter().enumerate() {
5017 let hole = li != 0;
5018 let edges = model.ordered_children_of(wire)?;
5019 let centre = {
5020 let samples = sample_wire(model, wire, 32, tol)?;
5021 #[allow(clippy::cast_precision_loss)]
5022 let n = samples.len() as f64;
5023 let sum = samples
5024 .iter()
5025 .fold(Vector::new(0.0, 0.0, 0.0), |acc, p| acc + p.to_vector());
5026 Point::from_vector(sum / n)
5027 };
5028 let mut pieces: Vec<(ogeom_geom::Curve, f64, f64)> = Vec::new();
5032 for edge in &edges {
5033 let Some((curve, range)) = ogeom_algo::placed_edge_curve(model, edge, tol)? else {
5034 ogeom_bail!(Construction, "a profile edge has no curve");
5035 };
5036 let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
5037 let (t0, t1) = if reversed { (range.1, range.0) } else { range };
5038 let closed =
5039 ogeom_algo::edge_vertices(model, edge)?.is_some_and(|(a, b)| a.is_same(&b));
5040 let parts = if closed { 4 } else { 1 };
5041 for k in 0..parts {
5042 #[allow(clippy::cast_precision_loss)]
5043 let (f0, f1) = (k as f64 / parts as f64, (k + 1) as f64 / parts as f64);
5044 pieces.push((curve.clone(), t0 + (t1 - t0) * f0, t0 + (t1 - t0) * f1));
5045 }
5046 }
5047 let starts: Vec<Point> = pieces
5050 .iter()
5051 .map(|(curve, a, _)| curve.point_at(*a, tol))
5052 .collect::<OgeomResult<_>>()?;
5053 let count = pieces.len();
5054 let piece_at = |pi: usize, f: f64| -> OgeomResult<Point> {
5057 let (curve, a, b) = &pieces[pi];
5058 if f <= 0.0 {
5059 Ok(starts[pi])
5060 } else if f >= 1.0 {
5061 Ok(starts[(pi + 1) % count])
5062 } else {
5063 curve.point_at(a + (b - a) * f, tol)
5064 }
5065 };
5066 let along_of = |pi: usize| -> usize {
5067 if matches!(pieces[pi].0, ogeom_geom::Curve::Line(_)) {
5068 8
5069 } else {
5070 24
5071 }
5072 };
5073 let skin_of = |pi: usize, seg: usize| -> OgeomResult<Skin<'_>> {
5078 Skin::swept(
5079 move |f, s| screw(piece_at(pi, f)?, theta_at(seg, s)),
5080 fractions(along_of(pi)),
5081 (0, PER_SEGMENT),
5082 false,
5083 )
5084 };
5085
5086 let mut corners: Vec<Vec<Shape>> = Vec::with_capacity(segments + 1);
5088 for b in 0..=segments {
5089 let theta = if b == segments {
5090 total
5091 } else {
5092 theta_at(b, 0.0)
5093 };
5094 let mut set = Vec::with_capacity(count);
5095 for p in &starts {
5096 set.push(ogeom_algo::make_vertex(model, screw(*p, theta)?).shape);
5097 }
5098 corners.push(set);
5099 }
5100 let mut bottoms = Vec::with_capacity(count);
5101 let mut tops = Vec::with_capacity(count);
5102 let mut held_tops: Vec<Option<Shape>> = vec![None; count];
5105 for seg in 0..segments {
5106 #[allow(clippy::cast_precision_loss)]
5107 let hint = screw(centre, theta_at(seg, (PER_SEGMENT / 2) as f64))?;
5108 let mut first_rail: Option<Shape> = None;
5109 let mut prev_rail: Option<Shape> = None;
5110 for ei in 0..count {
5111 let skin = skin_of(ei, seg)?;
5112 let next = (ei + 1) % count;
5113 let last_rail = if ei + 1 == count {
5114 first_rail.clone()
5115 } else {
5116 None
5117 };
5118 let (from, to) = (&corners[seg], &corners[seg + 1]);
5119 let strip = skinned_strip(
5120 model,
5121 &skin,
5122 (&from[ei], &from[next], &to[ei], &to[next]),
5123 [
5124 held_tops[ei].as_ref(),
5125 None,
5126 prev_rail.as_ref(),
5127 last_rail.as_ref(),
5128 ],
5129 hint,
5130 hole,
5131 tolerance,
5132 tol,
5133 )?;
5134 if ei == 0 {
5135 first_rail = Some(strip.rail0.clone());
5136 }
5137 prev_rail = Some(strip.rail1.clone());
5138 faces.push(strip.face.clone());
5139 if seg == 0 {
5140 bottoms.push(strip.bottom.clone());
5141 }
5142 if seg + 1 == segments {
5143 tops.push(strip.top.clone());
5144 }
5145 held_tops[ei] = Some(strip.top);
5146 }
5147 }
5148 cap_loops[0].push(bottoms);
5149 cap_loops[1].push(tops);
5150 }
5151
5152 for (end, loops) in cap_loops.iter().enumerate() {
5155 let theta = if end == 0 { 0.0 } else { total };
5156 let centre = centre_of(model, profile, tol)?;
5157 let at = screw(centre, theta)?;
5158 let normal = {
5161 let (sin, cos) = (sense * theta).sin_cos();
5162 let along = z * n.dot(z);
5163 let square = n - along;
5164 let turned = along + square * cos + z.cross(square) * sin;
5165 let forward = if turned.dot(travel(at)) >= 0.0 {
5166 turned
5167 } else {
5168 -turned
5169 };
5170 if end == 0 { -forward } else { forward }
5171 };
5172 let cap_plane = Plane::through(at, Direction::new(normal, tol)?);
5173 let mut reach = 1.0_f64;
5174 for edges in loops {
5175 for edge in edges {
5176 let (curve, range) = spine_curve_of(model, edge)?;
5177 for k in 0..8 {
5178 let p =
5179 curve.point_at(range.0 + (range.1 - range.0) * f64::from(k) / 8.0, tol)?;
5180 reach = reach.max(p.distance(at) * 2.0);
5181 }
5182 }
5183 }
5184 let surface: SurfaceGeometry =
5185 PlaneSurface::over(cap_plane, (-reach, reach), (-reach, reach))?.into();
5186 let mut wires = Vec::with_capacity(loops.len());
5189 for (li, edges) in loops.iter().enumerate() {
5190 let ring = walked_about(model, edges, normal, li == 0, tol)?;
5191 wires.push(ogeom_algo::make_wire(model, &ring, tol)?.shape);
5192 }
5193 let face = ogeom_algo::make_face(model, surface, &wires, tol)?.shape;
5194 let cap_id = {
5195 let Some(ogeom_topo::NodeData::Face(data)) = model.node(&face).map(|n| n.data()) else {
5196 ogeom_bail!(Construction, "the cap holds no face data");
5197 };
5198 data.surface
5199 };
5200 let frame = cap_plane.frame();
5201 for edges in loops {
5202 for edge in edges {
5203 let (curve, range) = spine_curve_of(model, edge)?;
5204 let ogeom_geom::Curve::BSpline(bs) = &curve else {
5205 ogeom_bail!(Construction, "a swept ring is not a spline");
5206 };
5207 let control2: Vec<Point2> = bs
5208 .control_points()
5209 .iter()
5210 .map(|w| {
5211 let local = frame.to_local(w.point());
5212 Point2::new(local.x, local.y)
5213 })
5214 .collect();
5215 let pcurve: ogeom_geom::PlanarCurve =
5216 ogeom_geom::BSpline2d::new(bs.knots().clone(), control2, tol)?.into();
5217 ogeom_algo::attach_pcurve(
5218 model,
5219 edge,
5220 pcurve,
5221 cap_id,
5222 ogeom_topo::Location::identity(),
5223 range,
5224 )?;
5225 }
5226 }
5227 faces.push(face);
5228 }
5229
5230 let sewn = sew(model, &faces, tol)?;
5231 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
5232 ogeom_bail!(Construction, "the helical sweep did not close");
5233 }
5234 let solid = make_solid(model, &sewn.shells)?.shape;
5235 let mut history = History::new();
5236 history.generate(profile, solid.clone());
5237 state_new_gaps(model, std::slice::from_ref(profile), &solid, tol)?;
5238 Ok(Built::new(solid, history))
5239}
5240
5241pub fn make_revolution_until(
5260 model: &mut Model,
5261 profile: &Shape,
5262 axis: ogeom_math::Axis,
5263 limit: &Shape,
5264 tol: Tolerances,
5265) -> OgeomResult<Built> {
5266 use ogeom_geom::Surface as _;
5267 if model.kind_of(profile)? != ShapeType::Face {
5268 ogeom_bail!(
5269 Construction,
5270 "a revolution up to a face revolves a planar face"
5271 );
5272 }
5273 if model.kind_of(limit)? != ShapeType::Face {
5274 ogeom_bail!(Construction, "a revolution stops on a face");
5275 }
5276 let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
5277 ogeom_bail!(
5278 Construction,
5279 "a revolution up to a face revolves a planar face"
5280 );
5281 };
5282 let z = axis.direction.vector();
5283 if plane.normal().vector().dot(z).abs() > tol.angular()
5284 || plane.distance_to(axis.location) > tol.confusion() * 100.0
5285 {
5286 ogeom_bail!(
5287 Construction,
5288 "the profile's plane does not hold the axis; a revolution up to a \
5289 face turns a profile about an axis in its own plane"
5290 );
5291 }
5292 let (surface, flip) = {
5294 let Some(data) = model.node(limit).and_then(|n| n.data().as_face()) else {
5295 ogeom_bail!(Construction, "the limit holds no face data");
5296 };
5297 let Some(surface) = model.geometry().surface(data.surface) else {
5298 ogeom_bail!(Dangling, "the limit's surface is not in this model");
5299 };
5300 (
5301 surface
5302 .clone()
5303 .transformed(&limit.transform(model.datums())?, tol)?,
5304 limit.orientation() == ogeom_topo::Orientation::Reversed,
5305 )
5306 };
5307 let side = |p: Point| -> OgeomResult<f64> {
5308 let foot = ogeom_algo::project_on_surface(&surface, p, 16, tol)?;
5309 let (u, v) = foot.parameters;
5310 let n = surface.normal_at(u, v, tol)?.vector();
5311 let s = (p - foot.point).dot(n);
5312 Ok(if flip { -s } else { s })
5313 };
5314 let turn_of = |p: Point, theta: f64| Transform::rotation(axis, theta).apply(p);
5317 let mut rings = Vec::new();
5318 for wire in explore(model, profile, Filter::OfType(ShapeType::Wire))? {
5319 rings.extend(sample_wire(model, &wire, 64, tol)?);
5320 }
5321 let start_side = side(centre_of(model, profile, tol)?)?.signum();
5322 const STEPS: u32 = 360;
5323 for p in &rings {
5324 let mut met: Option<f64> = None;
5325 for k in 1..=STEPS {
5326 let theta = core::f64::consts::TAU * f64::from(k) / f64::from(STEPS);
5327 let here = side(turn_of(*p, theta))?;
5328 match met {
5329 None if here.signum() != start_side && here.abs() > tol.confusion() => {
5330 met = Some(theta);
5331 }
5332 Some(first)
5333 if theta <= core::f64::consts::PI
5334 && here.signum() == start_side
5335 && here.abs() > tol.confusion() =>
5336 {
5337 ogeom_bail!(
5338 Construction,
5339 "the circle through {p:?} meets the limit at {first} and \
5340 crosses back within half a turn; a revolution up to it \
5341 is not built there"
5342 );
5343 }
5344 _ => {}
5345 }
5346 }
5347 match met {
5348 None => ogeom_bail!(
5349 Construction,
5350 "the circle through {p:?} never meets the limit's surface"
5351 ),
5352 Some(first) if first > core::f64::consts::PI => ogeom_bail!(
5353 Construction,
5354 "the circle through {p:?} meets the limit only past half a turn"
5355 ),
5356 _ => {}
5357 }
5358 }
5359 let half = ogeom_algo::make_revolution(model, profile, axis, core::f64::consts::PI, tol)?;
5360 let inside = centre_of(model, profile, tol)?;
5361 let bound = ogeom_algo::make_half_space(model, limit, inside, tol)?.shape;
5362 let mut built = ogeom_bool::common(model, &half.shape, &bound, tol)?;
5363 built.history.generate(profile, built.shape.clone());
5364 Ok(built)
5365}
5366
5367fn centre_of(model: &Model, profile: &Shape, tol: Tolerances) -> OgeomResult<Point> {
5369 let Some(wire) = explore(model, profile, Filter::OfType(ShapeType::Wire))?
5370 .into_iter()
5371 .next()
5372 else {
5373 ogeom_bail!(Construction, "the profile has no loop");
5374 };
5375 let samples = sample_wire(model, &wire, 32, tol)?;
5376 #[allow(clippy::cast_precision_loss)]
5377 let n = samples.len() as f64;
5378 let sum = samples
5379 .iter()
5380 .fold(Vector::new(0.0, 0.0, 0.0), |acc, p| acc + p.to_vector());
5381 Ok(Point::from_vector(sum / n))
5382}
5383
5384struct Leg {
5387 start: Point,
5388 heading: (Vector, Vector),
5389 motion: Transform,
5390 along: LegKind,
5391}
5392
5393#[derive(Clone, Copy)]
5397enum LegKind {
5398 Line(Vector),
5399 Arc(ogeom_math::Axis, f64),
5400}
5401
5402fn exact_legs(
5411 model: &mut Model,
5412 profile: &Shape,
5413 spine: &Shape,
5414 frenet: bool,
5415 tolerance: f64,
5416 tol: Tolerances,
5417) -> OgeomResult<Option<Built>> {
5418 use ogeom_geom::{Curve, Curve3d as _};
5419 let edges: Vec<Shape> = match model.kind_of(spine)? {
5420 ShapeType::Edge => vec![spine.clone()],
5421 ShapeType::Wire => model.ordered_children_of(spine)?,
5422 _ => return Ok(None),
5423 };
5424 let solid = match model.kind_of(profile)? {
5425 ShapeType::Face => true,
5426 ShapeType::Wire => false,
5427 _ => return Ok(None),
5428 };
5429 if edges.is_empty() || (!solid && edges.len() > 1) {
5430 return Ok(None);
5431 }
5432 let mut legs = Vec::with_capacity(edges.len());
5433 for edge in &edges {
5434 let (curve, range) = spine_curve_of(model, edge)?;
5435 let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
5436 let (t0, t1) = if reversed { (range.1, range.0) } else { range };
5437 let (a, b) = (curve.point_at(t0, tol)?, curve.point_at(t1, tol)?);
5438 let sense = if reversed { -1.0 } else { 1.0 };
5439 let heading = (curve.d1_at(t0, tol)? * sense, curve.d1_at(t1, tol)? * sense);
5440 let basis = match &curve {
5441 Curve::Trimmed(t) => t.basis().clone(),
5442 other => other.clone(),
5443 };
5444 let (motion, along) = match basis {
5445 Curve::Line(_) => {
5446 if frenet {
5447 return Ok(None);
5448 }
5449 (Transform::translation(b - a), LegKind::Line(b - a))
5450 }
5451 Curve::Circle(c) => {
5452 let frame = c.circle().frame();
5453 let turn = (a - frame.origin()).cross(heading.0);
5455 let direction = if turn.dot(frame.z().vector()) > 0.0 {
5456 frame.z()
5457 } else {
5458 -frame.z()
5459 };
5460 let axis = ogeom_math::Axis {
5461 location: frame.origin(),
5462 direction,
5463 };
5464 let angle = (t1 - t0).abs();
5465 (Transform::rotation(axis, angle), LegKind::Arc(axis, angle))
5466 }
5467 _ => return Ok(None),
5468 };
5469 legs.push(Leg {
5470 start: a,
5471 heading,
5472 motion,
5473 along,
5474 });
5475 }
5476 let cornered = legs.windows(2).any(|pair| {
5481 let (x, y) = (pair[0].heading.1, pair[1].heading.0);
5482 x.cross(y).magnitude() > tol.angular() * x.magnitude() * y.magnitude() || x.dot(y) <= 0.0
5483 });
5484 if cornered {
5485 let lines: Option<Vec<(Point, Vector)>> = legs
5486 .iter()
5487 .map(|leg| match leg.along {
5488 LegKind::Line(v) => Some((leg.start, v)),
5489 LegKind::Arc(..) => None,
5490 })
5491 .collect();
5492 return match lines {
5493 Some(lines) if solid && !frenet => mitred_lines(model, profile, spine, &lines, tol),
5494 None if solid && !frenet => {
5495 match mitred_arcs::mitred_arcs(model, profile, spine, &legs, tol)? {
5496 Some(built) => Ok(Some(built)),
5497 None => open_lines::face_legs(model, profile, spine, tolerance, tol),
5498 }
5499 }
5500 _ => Ok(None),
5501 };
5502 }
5503 let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
5505 return Ok(None);
5506 };
5507 let t0 = legs[0].heading.0;
5508 if plane.normal().vector().cross(t0).magnitude() > tol.angular() * t0.magnitude()
5509 || plane.distance_to(legs[0].start) > tol.confusion() * 100.0
5510 {
5511 return Ok(None);
5512 }
5513
5514 let mut joined: Vec<Shape> = Vec::new();
5515 let mut carried = Transform::IDENTITY;
5516 for leg in &legs {
5517 let heading = carried.apply_vector(legs[0].heading.0);
5520 let section = realized_profile_wound(model, profile, &carried, Some(heading), tol)?;
5521 let piece = match leg.along {
5522 LegKind::Line(v) => ogeom_algo::make_prism(model, §ion, v, tol)?.shape,
5523 LegKind::Arc(axis, angle) => {
5524 ogeom_algo::make_revolution(model, §ion, axis, angle, tol)?.shape
5525 }
5526 };
5527 carried = leg.motion * carried;
5528 joined.push(piece);
5529 }
5530 let result = fuse_in_order(model, joined, tol)?;
5531 Ok(result.map(|shape| {
5532 let mut history = History::new();
5533 history.generate(spine, shape.clone());
5534 history.generate(profile, shape.clone());
5535 Built::new(shape, history)
5536 }))
5537}
5538
5539fn mitred_lines(
5551 model: &mut Model,
5552 profile: &Shape,
5553 spine: &Shape,
5554 legs: &[(Point, Vector)],
5555 tol: Tolerances,
5556) -> OgeomResult<Option<Built>> {
5557 if let (Some(first), Some(last)) = (legs.first(), legs.last())
5558 && first.0.distance(last.0 + last.1) <= tol.confusion() * 100.0
5559 {
5560 return Ok(None);
5561 }
5562 let bounds = ogeom_algo::shape_bounds(model, profile, tol)?;
5563 let (Some(low), Some(high)) = (bounds.low(), bounds.high()) else {
5564 return Ok(None);
5565 };
5566 let width = low.distance(high);
5567 let unit = |v: Vector| v / v.magnitude();
5568 let mut mitres: Vec<(Point, Vector)> = Vec::with_capacity(legs.len().saturating_sub(1));
5571 for pair in legs.windows(2) {
5572 let (d0, d1) = (unit(pair[0].1), unit(pair[1].1));
5573 if d0.dot(d1) < DOUBLED_BACK {
5576 return Ok(None);
5577 }
5578 mitres.push((pair[1].0, unit(d0 + d1)));
5579 }
5580 let reach = width * 2.0;
5582 for (i, (_, v)) in legs.iter().enumerate() {
5583 let corners = f64::from(u8::from(i > 0) + u8::from(i + 1 < legs.len()));
5584 if v.magnitude() <= reach * corners * 0.5 {
5585 return Ok(None);
5586 }
5587 }
5588
5589 let mut carried = Transform::IDENTITY;
5590 let mut joined: Vec<Shape> = Vec::new();
5591 for (i, &(_, v)) in legs.iter().enumerate() {
5592 let d = unit(v);
5593 let before = if i > 0 { reach } else { 0.0 };
5594 let after = if i + 1 < legs.len() { reach } else { 0.0 };
5595 let section = realized_profile_wound(
5596 model,
5597 profile,
5598 &(Transform::translation(-d * before) * carried),
5599 Some(d),
5600 tol,
5601 )?;
5602 let mut piece =
5603 ogeom_algo::make_prism(model, §ion, v + d * (before + after), tol)?.shape;
5604 for (at, normal, keep_ahead) in [
5606 (i > 0).then(|| (mitres[i - 1].0, mitres[i - 1].1, true)),
5607 (i + 1 < legs.len()).then(|| (mitres[i].0, mitres[i].1, false)),
5608 ]
5609 .into_iter()
5610 .flatten()
5611 {
5612 let plane = Plane::through(at, Direction::new(normal, tol)?);
5613 let face = ogeom_algo::make_natural_face(
5614 model,
5615 SurfaceGeometry::Plane(ogeom_geom::PlaneSurface::over(
5616 plane,
5617 (-reach * 4.0 - width, reach * 4.0 + width),
5618 (-reach * 4.0 - width, reach * 4.0 + width),
5619 )?),
5620 )?
5621 .shape;
5622 let side = if keep_ahead { normal } else { -normal };
5623 let half = ogeom_algo::make_half_space(model, &face, at + side * width, tol)?.shape;
5624 piece = ogeom_bool::common(model, &piece, &half, tol)?.shape;
5625 }
5626 carried = Transform::translation(v) * carried;
5629 if let Some(&(corner, next)) = legs.get(i + 1) {
5630 let n = unit(next);
5631 let axis = d.cross(n);
5632 if axis.magnitude() > tol.angular() {
5633 let angle = d.dot(n).clamp(-1.0, 1.0).acos();
5634 let rotation = Transform::rotation(
5635 ogeom_math::Axis {
5636 location: corner,
5637 direction: Direction::new(axis, tol)?,
5638 },
5639 angle,
5640 );
5641 carried = rotation * carried;
5642 }
5643 }
5644 joined.push(piece);
5645 }
5646 let result = fuse_in_order(model, joined, tol)?;
5647 Ok(result.map(|shape| {
5648 let mut history = History::new();
5649 history.generate(spine, shape.clone());
5650 history.generate(profile, shape.clone());
5651 Built::new(shape, history)
5652 }))
5653}
5654
5655pub fn make_pipe_shell(
5702 model: &mut Model,
5703 profile: &Shape,
5704 spine: &Shape,
5705 frenet: bool,
5706 tolerance: f64,
5707 tol: Tolerances,
5708) -> OgeomResult<Built> {
5709 let law = if frenet {
5710 PipeLaw::Frenet
5711 } else {
5712 PipeLaw::RotationMinimizing
5713 };
5714 make_pipe_shell_with(
5715 model,
5716 profile,
5717 spine,
5718 &law,
5719 PipeCorners::Mitre,
5720 tolerance,
5721 tol,
5722 )
5723}
5724
5725#[derive(Debug, Clone, Copy)]
5727pub enum PipeLaw<'a> {
5728 RotationMinimizing,
5731 Frenet,
5733 Auxiliary {
5737 guide: &'a Shape,
5739 },
5740 Binormal(Direction),
5743 Fixed,
5746}
5747
5748#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
5750pub enum PipeCorners {
5751 #[default]
5753 Mitre,
5754 Extended,
5758 Round,
5773}
5774
5775pub fn make_pipe_shell_law(
5782 model: &mut Model,
5783 profile: &Shape,
5784 spine: &Shape,
5785 law: PipeLaw<'_>,
5786 tolerance: f64,
5787 tol: Tolerances,
5788) -> OgeomResult<Built> {
5789 make_pipe_shell_with(
5790 model,
5791 profile,
5792 spine,
5793 &law,
5794 PipeCorners::Mitre,
5795 tolerance,
5796 tol,
5797 )
5798}
5799
5800#[allow(clippy::too_many_arguments)]
5879pub fn make_pipe_shell_with(
5880 model: &mut Model,
5881 profile: &Shape,
5882 spine: &Shape,
5883 law: &PipeLaw<'_>,
5884 corners: PipeCorners,
5885 tolerance: f64,
5886 tol: Tolerances,
5887) -> OgeomResult<Built> {
5888 pipe_shell_ends(model, profile, spine, law, corners, false, tolerance, tol)
5889}
5890
5891#[allow(clippy::too_many_arguments)]
5896pub(crate) fn pipe_shell_ends(
5897 model: &mut Model,
5898 profile: &Shape,
5899 spine: &Shape,
5900 law: &PipeLaw<'_>,
5901 corners: PipeCorners,
5902 exact_ends: bool,
5903 tolerance: f64,
5904 tol: Tolerances,
5905) -> OgeomResult<Built> {
5906 let placed = placed_path(model, spine, tol)?;
5908 let guide = match law {
5909 PipeLaw::Auxiliary { guide } => Some(placed_path(model, guide, tol)?),
5910 _ => None,
5911 };
5912 let placed_law = match (law, &guide) {
5913 (PipeLaw::Auxiliary { .. }, Some(guide)) => PipeLaw::Auxiliary { guide },
5914 _ => *law,
5915 };
5916 let guide_moved =
5917 matches!((law, &guide), (PipeLaw::Auxiliary { guide: given }, Some(g)) if *given != g);
5918 if placed != *spine || guide_moved {
5919 let mut built = pipe_shell_ends(
5920 model,
5921 profile,
5922 &placed,
5923 &placed_law,
5924 corners,
5925 exact_ends,
5926 tolerance,
5927 tol,
5928 )?;
5929 built.history.generate(spine, built.shape.clone());
5930 return Ok(built);
5931 }
5932 if matches!(law, PipeLaw::Fixed) {
5935 return crate::fixed::fixed_pipe(model, profile, spine, tol);
5936 }
5937 let stored = match profile.orientation() {
5940 ogeom_topo::Orientation::Reversed => profile.reversed(),
5941 _ => profile.clone(),
5942 };
5943 if model.kind_of(profile)? == ShapeType::Wire
5944 && !ogeom_algo::is_wire_closed(model, &stored, tol)?
5945 && !spine_closes(model, spine, tol)?
5946 {
5947 if !open_lines::lines_and_arcs(model, spine)? {
5948 return open_skinned::open_wire_skinned(
5949 model, profile, spine, law, corners, tolerance, tol,
5950 );
5951 }
5952 if matches!(law, PipeLaw::RotationMinimizing) {
5953 return open_lines::open_wire_legs(model, profile, spine, corners, tolerance, tol);
5954 }
5955 let sharp = has_sharp_corner(model, spine, tol)?;
5956 if !(matches!(law, PipeLaw::Frenet) && corners == PipeCorners::Mitre) && sharp {
5958 return open_lines::open_wire_law(model, profile, spine, law, corners, tolerance, tol);
5959 }
5960 if !sharp {
5961 return open_skinned::open_wire_skinned(
5962 model, profile, spine, law, corners, tolerance, tol,
5963 );
5964 }
5965 }
5966 if model.kind_of(profile)? == ShapeType::Wire
5969 && !ogeom_algo::is_wire_closed(model, &stored, tol)?
5970 && spine_closes(model, spine, tol)?
5971 {
5972 if !open_lines::lines_and_arcs(model, spine)? {
5973 ogeom_bail!(
5974 Construction,
5975 "an open wire sweeps a ring round a closed spine of lines and arcs; \
5976 round a closed spine with any other edge it is not built"
5977 );
5978 }
5979 if matches!(law, PipeLaw::RotationMinimizing) {
5980 return open_lines::open_wire_legs(model, profile, spine, corners, tolerance, tol);
5981 }
5982 let sharp = has_sharp_corner(model, spine, tol)? || closes_on_a_corner(model, spine, tol)?;
5983 if matches!(law, PipeLaw::Frenet) && corners == PipeCorners::Mitre && sharp {
5984 ogeom_bail!(
5985 Construction,
5986 "a Frenet frame has no direction at a mitred corner; an open wire \
5987 round a cornered closed spine turns its corners round under it"
5988 );
5989 }
5990 return open_lines::open_wire_law(model, profile, spine, law, corners, tolerance, tol);
5991 }
5992 if corners != PipeCorners::Mitre
5993 && matches!(law, PipeLaw::RotationMinimizing)
5994 && model.kind_of(profile)? == ShapeType::Face
5995 && let Some(legs) = straight_legs(model, spine, tol)?
5996 && legs.len() > 1
5997 && !spine_closes(model, spine, tol)?
5998 {
5999 return cornered_lines(model, profile, spine, &legs, corners, tol);
6000 }
6001 let classic = matches!(law, PipeLaw::RotationMinimizing | PipeLaw::Frenet);
6002 let sharp = has_sharp_corner(model, spine, tol)? || closes_on_a_corner(model, spine, tol)?;
6003 if sharp && (corners != PipeCorners::Mitre || !classic) {
6004 return corners::law_cornered(model, profile, spine, law, corners, None, tolerance, tol);
6005 }
6006 if !classic && !sharp && spine_closes(model, spine, tol)? {
6007 if !corners::law_is_rotation_minimizing(model, spine, law, tolerance, tol)? {
6010 let still = evolving::Ramp::level(0.0);
6013 let built =
6014 evolving::closed_law_pipe(model, profile, spine, law, &still, tolerance, tol)?;
6015 state_new_gaps(model, &[profile.clone(), spine.clone()], &built.shape, tol)?;
6016 return Ok(built);
6017 }
6018 return make_pipe_shell_with(
6019 model,
6020 profile,
6021 spine,
6022 &PipeLaw::RotationMinimizing,
6023 corners,
6024 tolerance,
6025 tol,
6026 );
6027 }
6028 let built = pipe_shell_law(model, profile, spine, law, exact_ends, tolerance, tol)?;
6029 state_new_gaps(model, &[profile.clone(), spine.clone()], &built.shape, tol)?;
6030 Ok(built)
6031}
6032
6033fn spine_closes(model: &Model, spine: &Shape, tol: Tolerances) -> OgeomResult<bool> {
6035 let stations = shell_stations(model, spine, tol)?;
6036 Ok(stations[0].at.distance(stations[stations.len() - 1].at) <= tol.confusion() * 10.0)
6037}
6038
6039fn closes_on_a_corner(model: &Model, spine: &Shape, tol: Tolerances) -> OgeomResult<bool> {
6041 if !spine_closes(model, spine, tol)? {
6042 return Ok(false);
6043 }
6044 let stations = shell_stations(model, spine, tol)?;
6045 let (a, b) = (stations[stations.len() - 1].tangent, stations[0].tangent);
6046 Ok(a.cross(b).magnitude() > tol.angular() || a.dot(b) < 0.0)
6047}
6048
6049pub(crate) fn profile_square_at_start(
6053 model: &Model,
6054 plane: &Plane,
6055 spine: &Shape,
6056 at: Point,
6057 tol: Tolerances,
6058) -> OgeomResult<()> {
6059 let exact_t0 = {
6060 let first = match model.kind_of(spine)? {
6061 ShapeType::Edge => spine.clone(),
6062 _ => model.ordered_children_of(spine)?[0].clone(),
6063 };
6064 let (curve, range) = spine_curve_of(model, &first)?;
6065 let reversed = first.orientation() == ogeom_topo::Orientation::Reversed;
6066 let d = curve.d1_at(if reversed { range.1 } else { range.0 }, tol)?;
6067 let d = if reversed { -d } else { d };
6068 d / d.magnitude()
6069 };
6070 if plane.normal().vector().cross(exact_t0).magnitude() > tol.angular() {
6071 ogeom_bail!(
6072 Construction,
6073 "the profile leans along its spine; a pipe shell runs square to \
6074 the start"
6075 );
6076 }
6077 if plane.distance_to(at) > tol.confusion() * 100.0 {
6078 ogeom_bail!(
6079 Construction,
6080 "the profile does not sit at the spine's start"
6081 );
6082 }
6083 Ok(())
6084}
6085
6086fn straight_legs(
6089 model: &Model,
6090 spine: &Shape,
6091 tol: Tolerances,
6092) -> OgeomResult<Option<Vec<(Point, Vector)>>> {
6093 use ogeom_geom::Curve;
6094 let edges: Vec<Shape> = match model.kind_of(spine)? {
6095 ShapeType::Edge => vec![spine.clone()],
6096 ShapeType::Wire => model.ordered_children_of(spine)?,
6097 _ => return Ok(None),
6098 };
6099 let mut legs = Vec::with_capacity(edges.len());
6100 for edge in &edges {
6101 let (curve, range) = spine_curve_of(model, edge)?;
6102 let basis = match &curve {
6103 Curve::Trimmed(t) => t.basis().clone(),
6104 other => other.clone(),
6105 };
6106 if !matches!(basis, Curve::Line(_)) {
6107 return Ok(None);
6108 }
6109 let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
6110 let (t0, t1) = if reversed { (range.1, range.0) } else { range };
6111 let (a, b) = (curve.point_at(t0, tol)?, curve.point_at(t1, tol)?);
6112 legs.push((a, b - a));
6113 }
6114 Ok(Some(legs))
6115}
6116
6117pub(crate) fn has_sharp_corner(model: &Model, spine: &Shape, tol: Tolerances) -> OgeomResult<bool> {
6119 let stations = shell_stations(model, spine, tol)?;
6120 Ok((0..stations.len() - 1).any(|i| {
6121 stations[i].at.distance(stations[i + 1].at) <= tol.confusion()
6122 && (stations[i]
6123 .tangent
6124 .cross(stations[i + 1].tangent)
6125 .magnitude()
6126 > tol.angular()
6127 || stations[i].tangent.dot(stations[i + 1].tangent) < 0.0)
6128 }))
6129}
6130
6131fn cornered_lines(
6136 model: &mut Model,
6137 profile: &Shape,
6138 spine: &Shape,
6139 legs: &[(Point, Vector)],
6140 corners: PipeCorners,
6141 tol: Tolerances,
6142) -> OgeomResult<Built> {
6143 if let (Some(first), Some(last)) = (legs.first(), legs.last())
6144 && first.0.distance(last.0 + last.1) <= tol.confusion() * 100.0
6145 {
6146 ogeom_bail!(
6147 Construction,
6148 "extended and round corners are built along an open spine"
6149 );
6150 }
6151 let unit = |v: Vector| v / v.magnitude();
6152 let Some(outer) = explore(model, profile, Filter::OfType(ShapeType::Wire))?
6153 .into_iter()
6154 .next()
6155 else {
6156 ogeom_bail!(Construction, "the profile has no loop");
6157 };
6158 let samples = sample_wire(model, &outer, 256, tol)?;
6159 let (carried, turns) = carried_down_lines(legs, tol)?;
6160 let reach_past = |section: &Transform, corner: Point, along: Vector, turn: f64| {
6163 let far = samples
6164 .iter()
6165 .map(|p| (section.apply(*p) - corner).dot(along))
6166 .fold(0.0_f64, f64::max);
6167 far / turn.sin().max(LEAST_TURN_SINE)
6168 };
6169 let mut joined: Vec<Shape> = Vec::new();
6170 for (i, &(_, v)) in legs.iter().enumerate() {
6171 let d = unit(v);
6172 let (mut before, mut after) = (0.0, 0.0);
6173 if corners == PipeCorners::Extended {
6174 if i > 0
6175 && let Some((_, angle)) = turns[i - 1]
6176 {
6177 let at_corner = Transform::translation(legs[i - 1].1) * carried[i - 1];
6179 before = reach_past(&at_corner, legs[i].0, -d, angle);
6180 }
6181 if let Some((_, angle)) = turns[i] {
6182 after = reach_past(&carried[i + 1], legs[i + 1].0, d, angle);
6184 }
6185 }
6186 let section = realized_profile_wound(
6187 model,
6188 profile,
6189 &(Transform::translation(-d * before) * carried[i]),
6190 Some(d),
6191 tol,
6192 )?;
6193 let mut piece =
6194 ogeom_algo::make_prism(model, §ion, v + d * (before + after), tol)?.shape;
6195 if corners == PipeCorners::Round
6196 && let Some((axis, angle)) = turns[i]
6197 {
6198 let end = realized_profile_wound(
6199 model,
6200 profile,
6201 &(Transform::translation(v) * carried[i]),
6202 Some(d),
6203 tol,
6204 )?;
6205 let next = unit(legs[i + 1].1);
6209 let inward = next - d * next.dot(d);
6210 let outer = outside_half(model, &end, axis.location, d, -inward, tol)?;
6211 let bend = ogeom_algo::make_revolution(model, &outer, axis, angle, tol)?.shape;
6212 piece = ogeom_bool::fuse(model, &piece, &bend, tol)?.shape;
6213 }
6214 joined.push(piece);
6215 }
6216 let result = fuse_in_order(model, joined, tol)?;
6217 let Some(shape) = result else {
6218 ogeom_bail!(Construction, "the spine has no leg");
6219 };
6220 let mut history = History::new();
6221 history.generate(spine, shape.clone());
6222 history.generate(profile, shape.clone());
6223 Ok(Built::new(shape, history))
6224}
6225
6226type Turn = Option<(ogeom_math::Axis, f64)>;
6229
6230fn carried_down_lines(
6240 legs: &[(Point, Vector)],
6241 tol: Tolerances,
6242) -> OgeomResult<(Vec<Transform>, Vec<Turn>)> {
6243 let unit = |v: Vector| v / v.magnitude();
6244 let mut carried: Vec<Transform> = Vec::with_capacity(legs.len());
6245 let mut turns: Vec<Turn> = Vec::with_capacity(legs.len());
6246 let mut motion = Transform::IDENTITY;
6247 for (i, &(_, v)) in legs.iter().enumerate() {
6248 carried.push(motion);
6249 motion = Transform::translation(v) * motion;
6250 let turn = match legs.get(i + 1) {
6251 Some(&(corner, next)) => {
6252 let (d, n) = (unit(v), unit(next));
6253 if d.dot(n) < DOUBLED_BACK {
6254 ogeom_bail!(
6255 Construction,
6256 "the spine all but doubles back at a corner; no corner \
6257 of that kind turns it"
6258 );
6259 }
6260 let axis = d.cross(n);
6264 let sine = axis.magnitude();
6265 if sine > tol.angular() {
6266 let about = ogeom_math::Axis {
6267 location: corner,
6268 direction: Direction::unit(axis / sine, tol)?,
6269 };
6270 let angle = sine.atan2(d.dot(n));
6271 motion = Transform::rotation(about, angle) * motion;
6272 Some((about, angle))
6273 } else {
6274 None
6275 }
6276 }
6277 None => None,
6278 };
6279 turns.push(turn);
6280 }
6281 Ok((carried, turns))
6282}
6283
6284fn outside_half(
6289 model: &mut Model,
6290 section: &Shape,
6291 at: Point,
6292 normal: Vector,
6293 outward: Vector,
6294 tol: Tolerances,
6295) -> OgeomResult<Shape> {
6296 let width = {
6297 let bounds = ogeom_algo::shape_bounds(model, section, tol)?;
6298 match (bounds.low(), bounds.high()) {
6299 (Some(a), Some(b)) => a.distance(b),
6300 _ => ogeom_bail!(Construction, "the section has no extent"),
6301 }
6302 };
6303 let n = normal / normal.magnitude();
6304 let slab = ogeom_algo::make_prism(model, section, n * width, tol)?.shape;
6305 let cut = Plane::through(at, Direction::new(outward, tol)?);
6306 let face = ogeom_algo::make_natural_face(
6307 model,
6308 SurfaceGeometry::Plane(ogeom_geom::PlaneSurface::over(
6309 cut,
6310 (-width * 4.0, width * 4.0),
6311 (-width * 4.0, width * 4.0),
6312 )?),
6313 )?
6314 .shape;
6315 let side = outward / outward.magnitude() * width;
6316 let half = ogeom_algo::make_half_space(model, &face, at + side, tol)?.shape;
6317 let kept = ogeom_bool::common(model, &slab, &half, tol)?.shape;
6318 for face in explore(model, &kept, Filter::OfType(ShapeType::Face))? {
6319 let Ok((point, facing)) = ogeom_algo::face_normal(model, &face, tol) else {
6320 continue;
6321 };
6322 if (point - at).dot(n).abs() <= tol.confusion() * 100.0
6323 && facing.cross(n).magnitude() <= tol.angular() * 10.0
6324 {
6325 return Ok(if facing.dot(n) > 0.0 {
6327 face
6328 } else {
6329 face.reversed()
6330 });
6331 }
6332 }
6333 ogeom_bail!(
6334 Construction,
6335 "the section has no part outside the corner's axis to turn"
6336 )
6337}
6338
6339fn densified(
6344 model: &Model,
6345 spine: &Shape,
6346 stations: Vec<SpineStation>,
6347 law: &PipeLaw<'_>,
6348 tolerance: f64,
6349 tol: Tolerances,
6350) -> OgeomResult<Vec<SpineStation>> {
6351 let edges: Vec<Shape> = match model.kind_of(spine)? {
6352 ShapeType::Edge => vec![spine.clone()],
6353 ShapeType::Wire => model.ordered_children_of(spine)?,
6354 _ => return Ok(stations),
6355 };
6356 let mut curves = Vec::with_capacity(edges.len());
6357 for edge in &edges {
6358 curves.push(spine_curve_of(model, edge)?.0);
6359 }
6360 let mut stations = stations;
6361 for _ in 0..12 {
6362 let normals = law_normals(model, &stations, law, tolerance, tol)?;
6363 let mut out: Vec<SpineStation> = Vec::with_capacity(stations.len() * 2);
6364 let mut added = false;
6365 for i in 0..stations.len() {
6366 out.push(stations[i]);
6367 let Some(next) = stations.get(i + 1) else {
6368 continue;
6369 };
6370 let here = stations[i];
6371 if here.edge != next.edge || here.at.distance(next.at) <= tol.confusion() {
6372 continue;
6373 }
6374 let turn = normals[i].dot(normals[i + 1]).clamp(-1.0, 1.0).acos();
6375 if turn <= MOST_TURN {
6376 continue;
6377 }
6378 let curve = &curves[here.edge];
6379 let t = f64::midpoint(here.t, next.t);
6380 let d = curve.d1_at(t, tol)?;
6381 let sense = if curve.d1_at(here.t, tol)?.dot(here.tangent) >= 0.0 {
6382 1.0
6383 } else {
6384 -1.0
6385 };
6386 out.push(SpineStation {
6387 at: curve.point_at(t, tol)?,
6388 tangent: d * sense / d.magnitude(),
6389 edge: here.edge,
6390 t,
6391 });
6392 added = true;
6393 }
6394 stations = out;
6395 if !added {
6396 break;
6397 }
6398 if stations.len() > MOST_STATIONS {
6402 ogeom_bail!(
6403 NotDone,
6404 "the frame law turns faster than {MOST_STATIONS} sections can follow; \
6405 it jumps somewhere along the spine"
6406 );
6407 }
6408 }
6409 Ok(stations)
6410}
6411
6412const DOUBLED_BACK: f64 = -0.7;
6415
6416const LEAST_TURN_SINE: f64 = 1e-3;
6419
6420const MOST_TURN: f64 = 0.05;
6423
6424const MOST_STATIONS: usize = 2048;
6426
6427pub(crate) fn station_frame(
6430 station: &SpineStation,
6431 normal: Vector,
6432 tol: Tolerances,
6433) -> OgeomResult<Frame> {
6434 Frame::new(
6435 station.at,
6436 Direction::new(station.tangent, tol)?,
6437 Direction::new(normal, tol)?,
6438 tol,
6439 )
6440}
6441
6442fn moved_range(
6447 curve: &ogeom_geom::Curve,
6448 range: (f64, f64),
6449 motion: &Transform,
6450 tol: Tolerances,
6451) -> OgeomResult<(f64, f64)> {
6452 let k = motion.scale_factor().abs();
6453 if k == 1.0 {
6454 return Ok(range);
6455 }
6456 let ends = (
6457 motion.apply(curve.point_at(range.0, tol)?),
6458 motion.apply(curve.point_at(range.1, tol)?),
6459 );
6460 let moved = curve.transformed(motion, tol)?;
6461 for candidate in [range, (range.0 * k, range.1 * k)] {
6462 let (Ok(a), Ok(b)) = (
6464 moved.point_at(candidate.0, tol),
6465 moved.point_at(candidate.1, tol),
6466 ) else {
6467 continue;
6468 };
6469 if a.distance(ends.0) <= tol.confusion() && b.distance(ends.1) <= tol.confusion() {
6470 return Ok(candidate);
6471 }
6472 }
6473 ogeom_bail!(
6474 Construction,
6475 "a scaled ring's edge parameter is neither a length nor kept"
6476 )
6477}
6478
6479fn moved_ring(
6483 model: &mut Model,
6484 ring: &Shape,
6485 motion: &Transform,
6486 tol: Tolerances,
6487) -> OgeomResult<Shape> {
6488 let edges = model.ordered_children_of(ring)?;
6489 let mut vertices: Vec<(Point, Shape)> = Vec::new();
6490 let mut vertex = |model: &mut Model, at: Point| -> Shape {
6491 if let Some((_, v)) = vertices
6492 .iter()
6493 .find(|(p, _)| p.distance(at) <= tol.confusion() * 10.0)
6494 {
6495 return v.clone();
6496 }
6497 let v = ogeom_algo::make_vertex(model, at).shape;
6498 vertices.push((at, v.clone()));
6499 v
6500 };
6501 let mut moved = Vec::with_capacity(edges.len());
6502 for edge in &edges {
6503 let (curve, range) = spine_curve_of(model, edge)?;
6504 let placement = edge.transform(model.datums())?;
6505 let whole = *motion * placement;
6506 let range = moved_range(&curve, range, &whole, tol)?;
6507 let curve = curve.transformed(&whole, tol)?;
6508 let (a, b) = (curve.point_at(range.0, tol)?, curve.point_at(range.1, tol)?);
6509 let (va, vb) = (vertex(model, a), vertex(model, b));
6510 let built = ogeom_algo::make_edge_between(model, curve, range, &va, &vb, tol)?.shape;
6511 moved.push(if edge.orientation() == ogeom_topo::Orientation::Reversed {
6512 built.reversed()
6513 } else {
6514 built
6515 });
6516 }
6517 Ok(ogeom_algo::make_wire(model, &moved, tol)?.shape)
6518}
6519
6520fn centred_circle(
6524 model: &Model,
6525 ring: &Shape,
6526 station: &SpineStation,
6527 tol: Tolerances,
6528) -> OgeomResult<bool> {
6529 let mut radius: Option<f64> = None;
6530 for edge in model.ordered_children_of(ring)? {
6531 let (curve, _) = spine_curve_of(model, &edge)?;
6532 let curve = curve.transformed(&edge.transform(model.datums())?, tol)?;
6533 let basis = match &curve {
6534 Curve::Trimmed(t) => t.basis().clone(),
6535 other => other.clone(),
6536 };
6537 let Curve::Circle(c) = basis else {
6538 return Ok(false);
6539 };
6540 let circle = c.circle();
6541 let r = *radius.get_or_insert(circle.radius());
6542 if circle.centre().distance(station.at) > tol.confusion()
6543 || (circle.radius() - r).abs() > tol.confusion()
6544 || circle
6545 .frame()
6546 .z()
6547 .vector()
6548 .cross(station.tangent)
6549 .magnitude()
6550 > tol.angular()
6551 {
6552 return Ok(false);
6553 }
6554 }
6555 Ok(radius.is_some())
6556}
6557
6558fn disc_of(
6561 model: &mut Model,
6562 profile: &Shape,
6563 ring: &Shape,
6564 station: &SpineStation,
6565 tol: Tolerances,
6566) -> OgeomResult<Shape> {
6567 let Some(ogeom_topo::NodeData::Face(data)) = model.node(profile).map(|n| n.data()) else {
6568 ogeom_bail!(Construction, "a holed section is a face");
6569 };
6570 let Some(surface) = model.geometry().surface(data.surface).cloned() else {
6571 ogeom_bail!(Dangling, "surface is not in this model");
6572 };
6573 let edges = model.ordered_children_of(ring)?;
6574 let disc = make_face_with_pcurves(model, surface, &[edges], tol)?.shape;
6575 realized_profile_wound(
6576 model,
6577 &disc,
6578 &Transform::IDENTITY,
6579 Some(station.tangent),
6580 tol,
6581 )
6582}
6583
6584#[allow(clippy::too_many_arguments)]
6590fn law_loft(
6591 model: &mut Model,
6592 profile: &Shape,
6593 spine: &Shape,
6594 stations: &[SpineStation],
6595 law: &PipeLaw<'_>,
6596 evolution: Option<&evolving::Evolution>,
6597 exact_ends: bool,
6598 tolerance: f64,
6599 tol: Tolerances,
6600) -> OgeomResult<Built> {
6601 let rings: Vec<Shape> = match model.kind_of(profile)? {
6602 ShapeType::Face => model.ordered_children_of(profile)?,
6603 ShapeType::Wire => vec![profile.clone()],
6604 _ => ogeom_bail!(Construction, "a pipe sweeps a planar face or wire"),
6605 };
6606 if rings.len() > 1 {
6607 let mut solids = Vec::with_capacity(rings.len());
6616 for ring in &rings {
6617 let tube = if exact_ends
6618 && evolution.is_none()
6619 && centred_circle(model, ring, &stations[0], tol)?
6620 {
6621 let disc = disc_of(model, profile, ring, &stations[0], tol)?;
6622 exact_legs(model, &disc, spine, false, tolerance, tol)?
6623 } else {
6624 None
6625 };
6626 solids.push(match tube {
6627 Some(tube) => tube.shape,
6628 None => {
6629 law_loft(
6630 model, ring, spine, stations, law, evolution, exact_ends, tolerance, tol,
6631 )?
6632 .shape
6633 }
6634 });
6635 }
6636 let mut shape = solids[0].clone();
6637 for hole in &solids[1..] {
6638 shape = ogeom_bool::cut(model, &shape, hole, tol)?.shape;
6639 }
6640 let mut history = History::new();
6641 history.generate(spine, shape.clone());
6642 history.generate(profile, shape.clone());
6643 return Ok(Built::new(shape, history));
6644 }
6645 let normals = law_normals(model, stations, law, tolerance, tol)?;
6646 let start = station_frame(&stations[0], normals[0], tol)?;
6647 let mut kept: Vec<SpineStation> = Vec::with_capacity(stations.len());
6648 let mut kept_normals: Vec<Vector> = Vec::with_capacity(stations.len());
6649 for (station, normal) in stations.iter().zip(&normals) {
6650 if kept
6651 .last()
6652 .is_some_and(|p| p.at.distance(station.at) <= tol.confusion())
6653 {
6654 continue;
6655 }
6656 kept.push(*station);
6657 kept_normals.push(*normal);
6658 }
6659 let walk = SpineWalk {
6663 curves: walk_curves(model, spine)?,
6664 stations: &kept,
6665 normals: &kept_normals,
6666 };
6667 let lengths = match evolution {
6668 Some(_) => Some(evolving::SpineLengths::of(&walk.curves, tol)?),
6669 None => None,
6670 };
6671 let evolved = |s: f64| -> OgeomResult<Transform> {
6673 let (Some(evolution), Some(lengths)) = (evolution, &lengths) else {
6674 return Ok(Transform::IDENTITY);
6675 };
6676 #[allow(clippy::cast_precision_loss)]
6677 let last = (kept.len() - 1) as f64;
6678 let s = s.clamp(0.0, last);
6679 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
6680 let j = s.floor() as usize;
6681 #[allow(clippy::cast_precision_loss)]
6682 let f = s - j as f64;
6683 let (edge, t) = if f <= 0.0 {
6684 (kept[j].edge, kept[j].t)
6685 } else {
6686 walk.place_between(j, f)
6687 };
6688 evolution.local(lengths.fraction(&walk.curves, edge, t, tol)?, tol)
6689 };
6690 let mut sections: Vec<Shape> = Vec::with_capacity(kept.len());
6691 for (i, (station, normal)) in kept.iter().zip(&kept_normals).enumerate() {
6692 let frame = station_frame(station, *normal, tol)?;
6693 #[allow(clippy::cast_precision_loss)]
6694 let local = evolved(i as f64)?;
6695 let motion = Transform::from_frame(&frame) * local * Transform::to_frame(&start);
6696 sections.push(moved_ring(model, &rings[0], &motion, tol)?);
6697 }
6698 let carried = matches!(law, PipeLaw::RotationMinimizing | PipeLaw::Frenet);
6699 let held: std::cell::RefCell<ogeom_core::FastMap<u64, Transform>> =
6700 std::cell::RefCell::default();
6701 let guide = guide_curves(model, law)?;
6702 let motion = |s: f64| -> OgeomResult<Transform> {
6703 if let Some(m) = held.borrow().get(&s.to_bits()) {
6704 return Ok(*m);
6705 }
6706 let (at, tangent, carried_normal) = walk.frame_at(s, (0, kept.len() - 1), tol)?;
6707 let here = SpineStation {
6708 at,
6709 tangent,
6710 ..kept[0]
6711 };
6712 let normal = if carried {
6713 carried_normal
6714 } else {
6715 law_normals_on(&guide, &[here], law, tolerance, tol)?[0]
6716 };
6717 let frame = station_frame(&here, normal, tol)?;
6718 let m = Transform::from_frame(&frame) * evolved(s)? * Transform::to_frame(&start);
6719 held.borrow_mut().insert(s.to_bits(), m);
6720 Ok(m)
6721 };
6722 let mut built =
6723 loft_skinned_along(model, §ions, Some(&motion), exact_ends, tolerance, tol)?;
6724 built.history.generate(spine, built.shape.clone());
6725 built.history.generate(profile, built.shape.clone());
6726 Ok(built)
6727}
6728
6729pub fn make_pipe_sections(
6754 model: &mut Model,
6755 sections: &[Shape],
6756 spine: &Shape,
6757 frenet: bool,
6758 tolerance: f64,
6759 tol: Tolerances,
6760) -> OgeomResult<Built> {
6761 const AROUND: usize = 64;
6762 if sections.len() < 2 {
6763 ogeom_bail!(
6764 Construction,
6765 "a multisection pipe needs at least two sections"
6766 );
6767 }
6768 let placed = placed_path(model, spine, tol)?;
6770 if placed != *spine {
6771 let mut built = make_pipe_sections(model, sections, &placed, frenet, tolerance, tol)?;
6772 built.history.generate(spine, built.shape.clone());
6773 return Ok(built);
6774 }
6775 if let Some(loops) = holed_sections(model, sections)? {
6776 let mut shape: Option<Shape> = None;
6779 for rings in &loops {
6780 let swept = make_pipe_sections(model, rings, spine, frenet, tolerance, tol)?.shape;
6781 shape = Some(match shape {
6782 None => swept,
6783 Some(outer) => ogeom_bool::cut(model, &outer, &swept, tol)?.shape,
6784 });
6785 }
6786 let Some(shape) = shape else {
6787 ogeom_bail!(Construction, "a multisection pipe's sections have no loop");
6788 };
6789 let mut history = History::new();
6790 history.generate(spine, shape.clone());
6791 for section in sections {
6792 history.generate(section, shape.clone());
6793 }
6794 return Ok(Built::new(shape, history));
6795 }
6796 let law = if frenet {
6797 PipeLaw::Frenet
6798 } else {
6799 PipeLaw::RotationMinimizing
6800 };
6801 let stations = shell_stations(model, spine, tol)?;
6802 let stations = evenly(model, spine, stations, 24, false, tol)?;
6803 let normals = law_normals(model, &stations, &law, tolerance, tol)?;
6804 let mut run = vec![0.0_f64];
6806 for pair in stations.windows(2) {
6807 let held = run[run.len() - 1];
6808 run.push(held + pair[0].at.distance(pair[1].at));
6809 }
6810 let mut placed: Vec<(f64, Section, Vec<Point>)> = Vec::with_capacity(sections.len());
6815 let mut apex: Option<(Point, bool)> = None;
6817 let mut last_ring: Option<Shape> = None;
6819 for (index, section) in sections.iter().enumerate() {
6820 if model.kind_of(section)? == ShapeType::Vertex {
6821 let Some(data) = model.node(section).and_then(|n| n.data().as_vertex()) else {
6822 ogeom_bail!(Dangling, "vertex is not in this model");
6823 };
6824 let point = section.transform(model.datums())?.apply(data.point);
6825 let reach = tolerance.max(tol.confusion() * 1e3);
6826 let (Some(head), Some(tail)) = (stations.first(), stations.last()) else {
6827 ogeom_bail!(Construction, "the spine has no stations");
6828 };
6829 let at_end = if index + 1 == sections.len() && point.distance(tail.at) <= reach {
6830 true
6831 } else if index == 0 && point.distance(head.at) <= reach {
6832 false
6833 } else {
6834 ogeom_bail!(
6835 Construction,
6836 "a point section stands first or last, where the spine \
6837 starts or ends"
6838 );
6839 };
6840 apex = Some((point, at_end));
6841 let along = if at_end { run[run.len() - 1] } else { 0.0 };
6844 placed.push((
6845 along,
6846 Section::Point(Point::ORIGIN),
6847 vec![Point::ORIGIN; AROUND],
6848 ));
6849 continue;
6850 }
6851 let ring = match model.kind_of(section)? {
6852 ShapeType::Face => {
6853 let rings = model.ordered_children_of(section)?;
6854 rings[0].clone()
6855 }
6856 ShapeType::Wire => section.clone(),
6857 _ => ogeom_bail!(
6858 Construction,
6859 "a section is a planar face or wire, or a point at an end"
6860 ),
6861 };
6862 let Some(plane) = ogeom_algo::find_plane(model, &ring, tol)? else {
6863 ogeom_bail!(Construction, "a section is not planar");
6864 };
6865 last_ring = Some(ring.clone());
6866 let side = |p: Point| (p - plane.origin()).dot(plane.normal().vector());
6867 let mut found: Option<(f64, usize, f64)> = None;
6868 for i in 0..stations.len() {
6869 let here = side(stations[i].at);
6870 let crossing = if here.abs() <= tol.confusion() {
6871 Some((run[i], i, 0.0))
6872 } else if let Some(next) = stations.get(i + 1) {
6873 let there = side(next.at);
6874 (here.signum() != there.signum() && there.abs() > tol.confusion()).then(|| {
6875 let f = here / (here - there);
6876 (run[i] + (run[i + 1] - run[i]) * f, i, f)
6877 })
6878 } else {
6879 None
6880 };
6881 if crossing.is_some() {
6882 found = crossing;
6883 break;
6884 }
6885 }
6886 let Some((along, i, f)) = found else {
6887 ogeom_bail!(Construction, "the spine does not cross a section's plane");
6888 };
6889 let j = (i + 1).min(stations.len() - 1);
6891 let at = stations[i].at + (stations[j].at - stations[i].at) * f;
6892 let tangent = stations[i].tangent * (1.0 - f) + stations[j].tangent * f;
6893 let normal = normals[i] * (1.0 - f) + normals[j] * f;
6894 let normal = normal - tangent * normal.dot(tangent) / tangent.dot(tangent);
6895 let frame = Frame::new(
6896 at,
6897 Direction::new(tangent, tol)?,
6898 Direction::new(normal, tol)?,
6899 tol,
6900 )?;
6901 let into = Transform::to_frame(&frame);
6902 let dense: Vec<Point> = dense_wire(model, &ring, tol)?
6903 .iter()
6904 .map(|p| into.apply(*p))
6905 .collect();
6906 let around = match placed.last() {
6907 Some((_, _, previous)) => matched_loop(&dense, AROUND, previous),
6908 None => ArcLoop::new(dense),
6909 };
6910 let samples = fractions(AROUND)[..AROUND]
6911 .iter()
6912 .map(|f| around.at(*f))
6913 .collect();
6914 placed.push((along, Section::Loop(around), samples));
6915 }
6916 for pair in placed.windows(2) {
6917 if pair[1].0 <= pair[0].0 + tol.confusion() {
6918 ogeom_bail!(
6919 Construction,
6920 "the sections stand out of order along the spine"
6921 );
6922 }
6923 }
6924 if let (Some((point, _)), Some(ring), 2) = (apex, &last_ring, sections.len())
6928 && straight_legs(model, spine, tol)?.is_some_and(|legs| legs.len() == 1)
6929 {
6930 let tip = model.add_vertex(VertexData::new(point));
6931 let mut built = make_loft(model, ring, &tip, tol)?;
6932 built.history.generate(spine, built.shape.clone());
6933 for section in sections {
6934 built.history.generate(section, built.shape.clone());
6935 }
6936 return Ok(built);
6937 }
6938 let (first, last) = (placed[0].0, placed[placed.len() - 1].0);
6941 let mut rings: Vec<Shape> = Vec::new();
6942 let mut ring_at = |model: &mut Model, along: f64, frame: &Frame| -> OgeomResult<()> {
6943 let k = placed
6944 .windows(2)
6945 .position(|w| along <= w[1].0 + tol.confusion())
6946 .unwrap_or(placed.len() - 2);
6947 let (a, b) = (&placed[k], &placed[k + 1]);
6948 let f = ((along - a.0) / (b.0 - a.0)).clamp(0.0, 1.0);
6949 let out = Transform::from_frame(frame);
6950 let target = tolerance * 0.1;
6954 let fitted = ogeom_geom::fit::fit_curve_sampled(
6955 |g| {
6956 let (p, q) = (a.1.at(g), b.1.at(g));
6957 Ok(out.apply(p + (q - p) * f))
6958 },
6959 &fractions(AROUND),
6960 true,
6961 3,
6962 target,
6963 tol,
6964 )?;
6965 if !fitted.met {
6966 ogeom_bail!(
6967 NotDone,
6968 "a blended section reached {} against a target of {target}",
6969 fitted.error
6970 );
6971 }
6972 let curve: ogeom_geom::Curve = fitted.curve.into();
6973 let range = curve.domain();
6974 let edge = ogeom_algo::make_edge(model, curve, range, tol)?.shape;
6975 rings.push(ogeom_algo::make_wire(model, &[edge], tol)?.shape);
6976 Ok(())
6977 };
6978 let frame_at = |i: usize| station_frame(&stations[i], normals[i], tol);
6979 let at_along = |along: f64| -> OgeomResult<Frame> {
6982 let i = run
6983 .windows(2)
6984 .position(|w| along <= w[1] + tol.confusion())
6985 .unwrap_or(run.len() - 2);
6986 let f = ((along - run[i]) / (run[i + 1] - run[i]).max(f64::MIN_POSITIVE)).clamp(0.0, 1.0);
6987 let at = stations[i].at + (stations[i + 1].at - stations[i].at) * f;
6988 let tangent = stations[i].tangent * (1.0 - f) + stations[i + 1].tangent * f;
6989 let normal = normals[i] * (1.0 - f) + normals[i + 1] * f;
6990 let normal = normal - tangent * normal.dot(tangent) / tangent.dot(tangent);
6991 Frame::new(
6992 at,
6993 Direction::new(tangent, tol)?,
6994 Direction::new(normal, tol)?,
6995 tol,
6996 )
6997 };
6998 if apex.is_none_or(|(_, at_end)| at_end) {
7001 ring_at(model, first, &at_along(first)?)?;
7002 }
7003 for (i, &along) in run.iter().enumerate() {
7004 if along > first + tol.confusion() && along < last - tol.confusion() {
7005 ring_at(model, along, &frame_at(i)?)?;
7006 }
7007 }
7008 if apex.is_none_or(|(_, at_end)| !at_end) {
7009 ring_at(model, last, &at_along(last)?)?;
7010 }
7011 if let Some((point, at_end)) = apex {
7012 if !at_end {
7013 rings.reverse();
7014 }
7015 rings.push(model.add_vertex(VertexData::new(point)));
7016 }
7017 let mut built = make_loft_skinned(model, &rings, tolerance, tol)?;
7018 built.history.generate(spine, built.shape.clone());
7019 for section in sections {
7020 built.history.generate(section, built.shape.clone());
7021 }
7022 Ok(built)
7023}
7024
7025fn holed_sections(model: &Model, sections: &[Shape]) -> OgeomResult<Option<Vec<Vec<Shape>>>> {
7029 let mut read: Vec<Vec<Shape>> = Vec::with_capacity(sections.len());
7030 for section in sections {
7031 read.push(match model.kind_of(section)? {
7032 ShapeType::Face => model.ordered_children_of(section)?,
7033 _ => vec![section.clone()],
7034 });
7035 }
7036 if read.iter().all(|rings| rings.len() == 1) {
7037 return Ok(None);
7038 }
7039 for section in sections {
7040 if model.kind_of(section)? == ShapeType::Vertex {
7041 ogeom_bail!(
7042 Construction,
7043 "a multisection pipe with holes runs between faces; a point section \
7044 closes no hole"
7045 );
7046 }
7047 }
7048 let count = read[0].len();
7049 if let Some(k) = read.iter().position(|rings| rings.len() != count) {
7050 ogeom_bail!(
7051 Construction,
7052 "every section of a multisection pipe has as many holes; section {k} has {} \
7053 and section 0 has {}",
7054 read[k].len() - 1,
7055 count - 1
7056 );
7057 }
7058 Ok(Some(
7059 (0..count)
7060 .map(|j| read.iter().map(|rings| rings[j].clone()).collect())
7061 .collect(),
7062 ))
7063}
7064
7065fn evenly(
7068 model: &Model,
7069 spine: &Shape,
7070 stations: Vec<SpineStation>,
7071 count: u32,
7072 keep: bool,
7073 tol: Tolerances,
7074) -> OgeomResult<Vec<SpineStation>> {
7075 evenly_by(model, spine, stations, &|_| count, keep, tol)
7076}
7077
7078fn evenly_by(
7080 model: &Model,
7081 spine: &Shape,
7082 stations: Vec<SpineStation>,
7083 count: &dyn Fn(usize) -> u32,
7084 keep: bool,
7085 tol: Tolerances,
7086) -> OgeomResult<Vec<SpineStation>> {
7087 let edges: Vec<Shape> = match model.kind_of(spine)? {
7088 ShapeType::Edge => vec![spine.clone()],
7089 ShapeType::Wire => model.ordered_children_of(spine)?,
7090 _ => return Ok(stations),
7091 };
7092 let mut out: Vec<SpineStation> = Vec::with_capacity(stations.len() + edges.len() * 32);
7093 for (e, edge) in edges.iter().enumerate() {
7094 let on: Vec<SpineStation> = stations.iter().copied().filter(|s| s.edge == e).collect();
7095 let (Some(first), Some(last)) = (on.first().copied(), on.last().copied()) else {
7096 continue;
7097 };
7098 let (curve, _) = spine_curve_of(model, edge)?;
7099 let sense = if curve.d1_at(first.t, tol)?.dot(first.tangent) >= 0.0 {
7100 1.0
7101 } else {
7102 -1.0
7103 };
7104 let mut ts: Vec<f64> = if keep {
7105 on.iter().map(|s| s.t).collect()
7106 } else {
7107 vec![first.t, last.t]
7108 };
7109 let count = count(e);
7110 for k in 1..count {
7111 ts.push(first.t + (last.t - first.t) * f64::from(k) / f64::from(count));
7112 }
7113 ts.sort_by(|a, b| {
7114 if first.t <= last.t {
7115 a.total_cmp(b)
7116 } else {
7117 b.total_cmp(a)
7118 }
7119 });
7120 ts.dedup_by(|a, b| (*a - *b).abs() <= tol.parametric());
7121 for t in ts {
7122 let d = curve.d1_at(t, tol)?;
7123 out.push(SpineStation {
7124 at: curve.point_at(t, tol)?,
7125 tangent: d * sense / d.magnitude(),
7126 edge: e,
7127 t,
7128 });
7129 }
7130 }
7131 Ok(out)
7132}
7133
7134pub(crate) fn law_normals(
7140 model: &Model,
7141 stations: &[SpineStation],
7142 law: &PipeLaw<'_>,
7143 reach: f64,
7144 tol: Tolerances,
7145) -> OgeomResult<Vec<Vector>> {
7146 law_normals_on(&guide_curves(model, law)?, stations, law, reach, tol)
7147}
7148
7149fn guide_curves(
7152 model: &Model,
7153 law: &PipeLaw<'_>,
7154) -> OgeomResult<Vec<(ogeom_geom::Curve, (f64, f64))>> {
7155 let PipeLaw::Auxiliary { guide } = law else {
7156 return Ok(Vec::new());
7157 };
7158 let edges: Vec<Shape> = match model.kind_of(guide)? {
7159 ShapeType::Edge => vec![(*guide).clone()],
7160 ShapeType::Wire => model.ordered_children_of(guide)?,
7161 _ => ogeom_bail!(Construction, "an auxiliary spine is an edge or a wire"),
7162 };
7163 let mut curves = Vec::with_capacity(edges.len());
7164 for edge in &edges {
7165 curves.push(spine_curve_of(model, edge)?);
7166 }
7167 Ok(curves)
7168}
7169
7170fn law_normals_on(
7173 curves: &[(ogeom_geom::Curve, (f64, f64))],
7174 stations: &[SpineStation],
7175 law: &PipeLaw<'_>,
7176 reach: f64,
7177 tol: Tolerances,
7178) -> OgeomResult<Vec<Vector>> {
7179 match law {
7180 PipeLaw::Fixed => ogeom_bail!(
7183 Construction,
7184 "a fixed section keeps its own frame; it has no frame normals"
7185 ),
7186 PipeLaw::RotationMinimizing => Ok(rmf_normals(stations)),
7187 PipeLaw::Frenet => frenet_normals(stations, tol),
7188 PipeLaw::Binormal(b) => stations
7189 .iter()
7190 .map(|s| {
7191 let t = s.tangent;
7192 let b = b.vector() - t * b.vector().dot(t);
7193 if b.magnitude() <= tol.angular() {
7194 ogeom_bail!(
7195 Construction,
7196 "the spine runs along the binormal at {:?}; no frame keeps it",
7197 s.at
7198 );
7199 }
7200 let n = (b / b.magnitude()).cross(t);
7201 Ok(n / n.magnitude())
7202 })
7203 .collect(),
7204 PipeLaw::Auxiliary { .. } => {
7205 let mut out = Vec::with_capacity(stations.len());
7206 let mut last: Option<Point> = None;
7207 for s in stations {
7208 let (p, t) = (s.at, s.tangent);
7209 let mut best: Option<Point> = None;
7213 for (curve, range) in curves {
7214 const STEPS: u32 = 256;
7215 let height = |u: f64| -> OgeomResult<(f64, Point)> {
7216 let q = curve.point_at(u, tol)?;
7217 Ok(((q - p).dot(t), q))
7218 };
7219 let at =
7220 |k: u32| range.0 + (range.1 - range.0) * f64::from(k) / f64::from(STEPS);
7221 let mut prev = height(at(0))?;
7222 for k in 1..=STEPS {
7223 let here = height(at(k))?;
7224 if prev.0 == 0.0 || prev.0.signum() != here.0.signum() {
7225 let q = if prev.0 == 0.0 {
7227 prev.1
7228 } else {
7229 let (mut lo, mut hi) = (at(k - 1), at(k));
7230 let mut f_lo = prev.0;
7231 for _ in 0..60 {
7232 let mid = f64::midpoint(lo, hi);
7233 let (f_mid, _) = height(mid)?;
7234 if f_mid.signum() == f_lo.signum() {
7235 lo = mid;
7236 f_lo = f_mid;
7237 } else {
7238 hi = mid;
7239 }
7240 }
7241 height(f64::midpoint(lo, hi))?.1
7242 };
7243 let near = last.unwrap_or(p);
7244 if best.is_none_or(|b| q.distance(near) < b.distance(near)) {
7245 best = Some(q);
7246 }
7247 }
7248 prev = here;
7249 }
7250 }
7251 if best.is_none() {
7252 let near = last.unwrap_or(p);
7253 for (curve, range) in curves {
7254 for u in [range.0, range.1] {
7255 let q = curve.point_at(u, tol)?;
7256 let h = (q - p).dot(t);
7257 if h.abs() > reach {
7258 continue;
7259 }
7260 let d = curve.d1_at(u, tol)?;
7261 let along = d.dot(t);
7262 let onto = if along.abs() > tol.angular() * d.magnitude() {
7263 q - d * (h / along)
7264 } else {
7265 q
7266 };
7267 let q = if onto.distance(q) <= 2.0 * reach {
7270 onto
7271 } else {
7272 q
7273 };
7274 if best.is_none_or(|b| q.distance(near) < b.distance(near)) {
7275 best = Some(q);
7276 }
7277 }
7278 }
7279 }
7280 let Some(q) = best else {
7281 ogeom_bail!(
7282 Construction,
7283 "the auxiliary spine does not cross the plane square to the \
7284 spine at {p:?}"
7285 );
7286 };
7287 last = Some(q);
7288 let toward = (q - p) - t * (q - p).dot(t);
7289 if toward.magnitude() <= tol.confusion() {
7290 ogeom_bail!(
7291 Construction,
7292 "the auxiliary spine meets the spine at {p:?}; no direction \
7293 points at it"
7294 );
7295 }
7296 out.push(toward / toward.magnitude());
7297 }
7298 Ok(out)
7299 }
7300 }
7301}
7302
7303fn law_stations(
7306 model: &Model,
7307 spine: &Shape,
7308 stations: Vec<SpineStation>,
7309 law: &PipeLaw<'_>,
7310 tolerance: f64,
7311 tol: Tolerances,
7312) -> OgeomResult<Vec<SpineStation>> {
7313 let wanted = densified(model, spine, stations.clone(), law, tolerance, tol)?.len();
7316 let mut lengths: Vec<f64> = Vec::new();
7319 for pair in stations.windows(2) {
7320 let e = pair[1].edge;
7321 if lengths.len() <= e {
7322 lengths.resize(e + 1, 0.0);
7323 }
7324 if pair[0].edge == e {
7325 lengths[e] += pair[0].at.distance(pair[1].at);
7326 }
7327 }
7328 let total: f64 = lengths.iter().sum();
7329 #[allow(
7330 clippy::cast_precision_loss,
7331 clippy::cast_possible_truncation,
7332 clippy::cast_sign_loss
7333 )]
7334 let share = |e: usize| -> u32 {
7335 let length = lengths.get(e).copied().unwrap_or(0.0);
7336 if total <= 0.0 {
7337 return 2;
7338 }
7339 ((wanted as f64 * length / total).ceil() as u32).max(2)
7340 };
7341 evenly_by(model, spine, stations, &share, false, tol)
7342}
7343
7344#[allow(clippy::too_many_lines)]
7346fn pipe_shell_law(
7347 model: &mut Model,
7348 profile: &Shape,
7349 spine: &Shape,
7350 law: &PipeLaw<'_>,
7351 exact_ends: bool,
7352 tolerance: f64,
7353 tol: Tolerances,
7354) -> OgeomResult<Built> {
7355 const AROUND: usize = 40;
7356 let frenet = matches!(law, PipeLaw::Frenet);
7357 let classic = matches!(law, PipeLaw::RotationMinimizing | PipeLaw::Frenet);
7358
7359 if classic && let Some(exact) = exact_legs(model, profile, spine, frenet, tolerance, tol)? {
7360 return Ok(exact);
7361 }
7362 let stations = shell_stations(model, spine, tol)?;
7363 let stations = if classic {
7364 stations
7365 } else {
7366 law_stations(model, spine, stations, law, tolerance, tol)?
7367 };
7368 let corners: Vec<usize> = (0..stations.len() - 1)
7370 .filter(|&i| {
7371 stations[i].at.distance(stations[i + 1].at) <= tol.confusion()
7372 && (stations[i]
7373 .tangent
7374 .cross(stations[i + 1].tangent)
7375 .magnitude()
7376 > tol.angular()
7377 || stations[i].tangent.dot(stations[i + 1].tangent) < 0.0)
7378 })
7379 .collect();
7380 let kinks: Vec<usize> = (0..stations.len() - 1)
7384 .filter(|&i| stations[i].at.distance(stations[i + 1].at) <= tol.confusion())
7385 .collect();
7386 let ring = stations[0].at.distance(stations[stations.len() - 1].at) <= tol.confusion() * 10.0;
7387 if !classic && (ring || !corners.is_empty()) {
7388 ogeom_bail!(
7389 Construction,
7390 "an auxiliary or binormal law sweeps an open spine with no sharp corner"
7391 );
7392 }
7393 if !classic {
7394 return law_loft(
7395 model, profile, spine, &stations, law, None, exact_ends, tolerance, tol,
7396 );
7397 }
7398 if ring && kinks.is_empty() {
7399 let still = evolving::Ramp::level(0.0);
7400 return closed_pipe_shell(model, profile, spine, stations, law, &still, tolerance, tol);
7401 }
7402 if frenet && !corners.is_empty() {
7410 ogeom_bail!(
7411 Construction,
7412 "a Frenet frame has no direction at a corner; sweep a cornered \
7413 spine with the rotation-minimizing frame"
7414 );
7415 }
7416 let normals = law_normals(model, &stations, law, tolerance, tol)?;
7417 let normals = if ring {
7422 let mut extended = stations.clone();
7423 extended.push(stations[0]);
7424 let carried = rmf_normals(&extended);
7425 let (n0, n_home) = (carried[0], carried[carried.len() - 1]);
7426 let t0 = stations[0].tangent;
7427 let twist = (n0.cross(n_home).dot(t0)).atan2(n0.dot(n_home));
7428 let mut lengths = vec![0.0_f64];
7429 for pair in extended.windows(2) {
7430 let held = lengths[lengths.len() - 1];
7431 lengths.push(held + pair[0].at.distance(pair[1].at));
7432 }
7433 let total = lengths[lengths.len() - 1];
7434 carried
7435 .iter()
7436 .take(stations.len())
7437 .enumerate()
7438 .map(|(i, n)| {
7439 let phi = -twist * lengths[i] / total;
7440 let t = extended[i].tangent;
7441 let v = *n * phi.cos() + t.cross(*n) * phi.sin();
7442 let v = v - t * v.dot(t);
7443 v / v.magnitude()
7444 })
7445 .collect()
7446 } else {
7447 normals
7448 };
7449 let mitre: Vec<Option<(Point, Vector)>> = {
7453 let mut out: Vec<Option<(Point, Vector)>> = vec![None; stations.len()];
7454 for &k in &kinks {
7455 let n = stations[k].tangent + stations[k + 1].tangent;
7456 if n.magnitude() <= tol.angular() {
7457 ogeom_bail!(
7458 Construction,
7459 "the spine doubles straight back on itself; no mitre \
7460 plane divides that corner"
7461 );
7462 }
7463 out[k] = Some((stations[k].at, n));
7464 out[k + 1] = Some((stations[k + 1].at, n));
7465 }
7466 if ring {
7467 let wrap = stations.len() - 1;
7468 let n = stations[wrap].tangent + stations[0].tangent;
7469 if n.magnitude() <= tol.angular() {
7470 ogeom_bail!(
7471 Construction,
7472 "the spine doubles straight back on itself; no mitre \
7473 plane divides that corner"
7474 );
7475 }
7476 out[wrap] = Some((stations[wrap].at, n));
7477 out[0] = Some((stations[0].at, n));
7478 }
7479 out
7480 };
7481 let runs: Vec<(usize, usize)> = {
7482 let mut out = Vec::with_capacity(kinks.len() + 1);
7483 let mut start = 0;
7484 for &k in &kinks {
7485 out.push((start, k));
7486 start = k + 1;
7487 }
7488 out.push((start, stations.len() - 1));
7489 out
7490 };
7491 let straight = |rs: usize, re: usize| -> bool {
7497 if !classic {
7498 return false;
7499 }
7500 let t0 = stations[rs].tangent;
7501 (rs..=re).all(|i| stations[i].tangent.cross(t0).magnitude() <= tol.angular())
7502 };
7503 struct CornerPair {
7511 before: (usize, usize),
7512 after: (usize, usize),
7513 curved: bool,
7514 }
7515 let corner_pairs: Vec<CornerPair> = {
7516 let mut out = Vec::new();
7517 for pair in runs.windows(2) {
7520 out.push(CornerPair {
7521 before: pair[0],
7522 after: pair[1],
7523 curved: corners.contains(&pair[0].1)
7524 && (!straight(pair[0].0, pair[0].1) || !straight(pair[1].0, pair[1].1)),
7525 });
7526 }
7527 if ring && runs.len() > 1 {
7528 let (before, after) = (runs[runs.len() - 1], runs[0]);
7529 let turns = stations[before.1]
7530 .tangent
7531 .cross(stations[after.0].tangent)
7532 .magnitude()
7533 > tol.angular()
7534 || stations[before.1].tangent.dot(stations[after.0].tangent) < 0.0;
7535 out.push(CornerPair {
7536 before,
7537 after,
7538 curved: turns && (!straight(before.0, before.1) || !straight(after.0, after.1)),
7539 });
7540 }
7541 out
7542 };
7543 let walk = SpineWalk {
7544 curves: walk_curves(model, spine)?,
7545 stations: &stations,
7546 normals: &normals,
7547 };
7548 let join_reach = tolerance.max(tol.confusion() * 100.0);
7549 let curved_join = |pair: &CornerPair, ab: (f64, f64)| -> OgeomResult<CornerJoin> {
7550 let join = walk.join(pair.before, pair.after, ab, tol)?;
7551 if join.gap > join_reach {
7552 ogeom_bail!(
7553 Construction,
7554 "a skew corner against a curved leg is still owed its frame \
7555 law: the legs' generators miss by {}; see docs/CAPABILITIES.md, \
7556 offset.sweeps",
7557 join.gap
7558 );
7559 }
7560 Ok(join)
7561 };
7562 let curved_at = |i: usize| -> Option<&CornerPair> {
7564 corner_pairs
7565 .iter()
7566 .find(|pair| pair.curved && (pair.before.1 == i || pair.after.0 == i))
7567 };
7568
7569 let loops: Vec<Shape> = match model.kind_of(profile)? {
7572 ShapeType::Face => explore(model, profile, Filter::OfType(ShapeType::Wire))?,
7573 ShapeType::Wire => vec![profile.clone()],
7574 other => ogeom_bail!(
7575 Construction,
7576 "a pipe shell sweeps a planar wire or face, not a {other:?}"
7577 ),
7578 };
7579 if loops.is_empty() {
7580 ogeom_bail!(Construction, "the profile has no loop to sweep");
7581 }
7582 let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
7583 ogeom_bail!(Construction, "a pipe shell sweeps a planar profile");
7584 };
7585 let t0 = stations[0].tangent;
7586 profile_square_at_start(model, &plane, spine, stations[0].at, tol)?;
7587
7588 let x0 = normals[0];
7594 let y0 = t0.cross(x0);
7595 let origin = stations[0].at;
7596 let flat = |p: Point| -> (f64, f64) { ((p - origin).dot(x0), (p - origin).dot(y0)) };
7597 let probes: Vec<(f64, f64)> = {
7599 let mut out = Vec::new();
7600 for wire in &loops {
7601 out.extend(sample_wire(model, wire, AROUND, tol)?.into_iter().map(flat));
7602 }
7603 out
7604 };
7605 let skew: Vec<(usize, usize)> = {
7610 let mut out = Vec::new();
7611 for pair in corner_pairs.iter().filter(|pair| pair.curved) {
7612 let mut worst = 0.0_f64;
7613 for ab in &probes {
7614 worst = worst.max(walk.join(pair.before, pair.after, *ab, tol)?.gap);
7615 }
7616 if worst > join_reach {
7617 out.push((pair.before.1, pair.after.0));
7618 }
7619 }
7620 out
7621 };
7622 if !skew.is_empty() {
7623 return mitred_pieces(
7624 model, profile, spine, &stations, &skew, ring, &probes, tolerance, tol,
7625 );
7626 }
7627 let place = |i: usize, (a, b): (f64, f64)| -> OgeomResult<Point> {
7628 if let Some(pair) = curved_at(i) {
7629 return Ok(curved_join(pair, (a, b))?.at);
7630 }
7631 let x = normals[i];
7632 let y = stations[i].tangent.cross(x);
7633 let p = stations[i].at + x * a + y * b;
7634 Ok(match mitre[i] {
7635 Some((corner, n)) => {
7636 let t = stations[i].tangent;
7637 p + t * ((corner - p).dot(n) / t.dot(n))
7638 }
7639 None => p,
7640 })
7641 };
7642 let one_section = |i: usize, j: usize| -> OgeomResult<bool> {
7653 for ab in &probes {
7654 if place(i, *ab)?.distance(place(j, *ab)?) > tol.confusion() {
7655 return Ok(false);
7656 }
7657 }
7658 Ok(true)
7659 };
7660 let mut butted_start = vec![false; runs.len()];
7661 for ri in 1..runs.len() {
7662 butted_start[ri] = corner_pairs
7663 .iter()
7664 .any(|pair| pair.curved && pair.after == runs[ri])
7665 || one_section(runs[ri - 1].1, runs[ri].0)?;
7666 }
7667 let butted_wrap = ring
7668 && runs.len() > 1
7669 && (corner_pairs.iter().any(|pair| {
7670 pair.curved && pair.before == runs[runs.len() - 1] && pair.after == runs[0]
7671 }) || one_section(runs[runs.len() - 1].1, runs[0].0)?);
7672 let shares_start = |ri: usize| -> bool { butted_start[ri] };
7673 let shares_end = |ri: usize| -> bool { butted_wrap && ri + 1 == runs.len() };
7674 struct RerowColumn {
7677 along: Vec<(f64, f64)>,
7678 length: f64,
7679 }
7680 let crossing =
7683 |run: (usize, usize), ab: (f64, f64), at_start: bool| -> OgeomResult<Option<CornerJoin>> {
7684 let pair = corner_pairs.iter().find(|pair| {
7685 pair.curved
7686 && if at_start {
7687 pair.after == run
7688 } else {
7689 pair.before == run
7690 }
7691 });
7692 pair.map(|pair| curved_join(pair, ab)).transpose()
7693 };
7694 let column_ends = |(rs, re): (usize, usize),
7699 ab: (f64, f64)|
7700 -> OgeomResult<(Option<CornerJoin>, Option<CornerJoin>)> {
7701 let run = (rs, re);
7702 let (start, end) = (crossing(run, ab, true)?, crossing(run, ab, false)?);
7703 #[allow(clippy::cast_precision_loss)]
7704 let (rsf, ref_) = (rs as f64, re as f64);
7705 if start.as_ref().is_some_and(|j| j.s2 >= ref_ - 0.5)
7706 || end.as_ref().is_some_and(|j| j.s1 <= rsf + 0.5)
7707 {
7708 ogeom_bail!(
7709 Construction,
7710 "a leg is shorter than its corner's reach; the mitre would \
7711 run off its far end"
7712 );
7713 }
7714 Ok((start, end))
7715 };
7716 let straight_column = |(rs, re): (usize, usize), ab: (f64, f64)| -> OgeomResult<Vec<Point>> {
7720 let (start, end) = column_ends((rs, re), ab)?;
7721 Ok(vec![
7722 match start {
7723 Some(j) => j.at,
7724 None => place(rs, ab)?,
7725 },
7726 match end {
7727 Some(j) => j.at,
7728 None => place(re, ab)?,
7729 },
7730 ])
7731 };
7732 let rerow_column = |(rs, re): (usize, usize), ab: (f64, f64)| -> OgeomResult<RerowColumn> {
7740 let run = (rs, re);
7741 let (start, end) = column_ends(run, ab)?;
7742 #[allow(clippy::cast_precision_loss)]
7743 let lo = start.map_or(rs as f64, |j| j.s2);
7744 #[allow(clippy::cast_precision_loss)]
7745 let hi = end.map_or(re as f64, |j| j.s1);
7746 let mut nodes: Vec<f64> = vec![lo];
7753 #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
7754 {
7755 let mut m = (lo * 8.0).floor() as i64 + 1;
7756 while (m as f64) / 8.0 < hi {
7757 let sp = (m as f64) / 8.0;
7758 if sp - lo > 1e-9 && hi - sp > 1e-9 {
7759 nodes.push(sp);
7760 }
7761 m += 1;
7762 }
7763 }
7764 nodes.push(hi);
7765 let mut along: Vec<(f64, f64)> = Vec::with_capacity(nodes.len());
7766 let mut prev: Option<Point> = None;
7767 let mut length = 0.0;
7768 for sp in nodes {
7769 let p = walk.generator(sp, run, ab, tol)?;
7770 if let Some(q) = prev {
7771 length += q.distance(p);
7772 }
7773 along.push((length, sp));
7774 prev = Some(p);
7775 }
7776 Ok(RerowColumn { along, length })
7777 };
7778 let rerowed_at =
7780 |run: (usize, usize), column: &RerowColumn, ab: (f64, f64), f: f64| -> OgeomResult<Point> {
7781 let along = &column.along;
7782 let target = column.length * f;
7783 let at = along
7784 .partition_point(|(l, _)| *l < target)
7785 .clamp(1, along.len() - 1);
7786 let ((l0, s0), (l1, s1)) = (along[at - 1], along[at]);
7787 let g = if l1 > l0 {
7788 ((target - l0) / (l1 - l0)).clamp(0.0, 1.0)
7789 } else {
7790 0.0
7791 };
7792 walk.generator(s0 + (s1 - s0) * g, run, ab, tol)
7793 };
7794 #[allow(clippy::type_complexity)]
7798 let rerowed_point = |(rs, re): (usize, usize),
7799 held: &std::cell::RefCell<ogeom_core::FastMap<u64, RerowColumn>>,
7800 u: f64,
7801 ab: (f64, f64),
7802 s: f64|
7803 -> OgeomResult<Point> {
7804 if !held.borrow().contains_key(&u.to_bits()) {
7805 let column = rerow_column((rs, re), ab)?;
7806 held.borrow_mut().insert(u.to_bits(), column);
7807 }
7808 let held = held.borrow();
7809 #[allow(clippy::cast_precision_loss)]
7810 let f = (s - rs as f64) / (re - rs) as f64;
7811 rerowed_at((rs, re), &held[&u.to_bits()], ab, f)
7812 };
7813 let plain = |run: (usize, usize)| -> bool {
7819 !straight(run.0, run.1)
7820 && !corner_pairs
7821 .iter()
7822 .any(|pair| pair.curved && (pair.after == run || pair.before == run))
7823 };
7824 let along_run = |run: (usize, usize), s: f64, ab: (f64, f64)| -> OgeomResult<Point> {
7825 if s.fract() == 0.0 {
7826 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
7827 let i = s as usize;
7828 place(i, ab)
7829 } else {
7830 walk.generator(s, run, ab, tol)
7831 }
7832 };
7833 let last = stations.len() - 1;
7834
7835 enum LoopWall {
7836 Ring {
7837 ring0: Shape,
7838 ring1: Shape,
7839 },
7840 Chain {
7841 bottoms: Vec<Shape>,
7842 tops: Vec<Shape>,
7843 },
7844 }
7845 let mut faces: Vec<Shape> = Vec::new();
7846 let mut ends: Vec<LoopWall> = Vec::with_capacity(loops.len());
7847 for (li, wire) in loops.iter().enumerate() {
7848 let hole = li != 0;
7849 let edges = model.ordered_children_of(wire)?;
7850 let single_smooth = edges.len() == 1 && {
7851 let (curve, _) = spine_curve_of(model, &edges[0])?;
7852 !matches!(curve, ogeom_geom::Curve::Line(_))
7853 && ogeom_algo::edge_vertices(model, &edges[0])?.is_some_and(|(a, b)| a.is_same(&b))
7854 };
7855 if single_smooth {
7856 let profile_loop = section_loop(model, wire, None, tol)?;
7857 let mut ring0: Option<Shape> = None;
7861 let mut ring1: Option<Shape> = None;
7862 for (ri, &(rs, re)) in runs.iter().enumerate() {
7863 let held = std::cell::RefCell::default();
7866 let skin = if plain((rs, re)) {
7867 Skin::swept(
7868 |f, s| along_run((rs, re), s, flat(profile_loop.at(f))),
7869 fractions(AROUND),
7870 (rs, re),
7871 true,
7872 )?
7873 } else if straight(rs, re) {
7874 Skin::columns(
7875 |f| straight_column((rs, re), flat(profile_loop.at(f))),
7876 fractions(AROUND),
7877 true,
7878 false,
7879 false,
7880 )?
7881 } else {
7882 Skin::swept(
7883 |f, s| rerowed_point((rs, re), &held, f, flat(profile_loop.at(f)), s),
7884 fractions(AROUND),
7885 (rs, re),
7886 true,
7887 )?
7888 };
7889 let shared_start = shares_start(ri).then_some(()).and(ring1.as_ref());
7890 let shared_end = shares_end(ri).then_some(()).and(ring0.as_ref());
7891 let wall = skinned_wall(model, &skin, (shared_start, shared_end), tolerance, tol)?;
7892 faces.push(if hole {
7893 wall.face.reversed()
7894 } else {
7895 wall.face.clone()
7896 });
7897 if ring0.is_none() {
7898 ring0 = Some(wall.ring0);
7899 }
7900 ring1 = Some(wall.ring1);
7901 }
7902 let (Some(ring0), Some(ring1)) = (ring0, ring1) else {
7903 ogeom_bail!(Construction, "the sweep produced no wall");
7904 };
7905 ends.push(LoopWall::Ring { ring0, ring1 });
7906 } else {
7907 let count = edges.len();
7909 let mut corner_flat: Vec<(f64, f64)> = Vec::with_capacity(count);
7910 for edge in &edges {
7911 let Some((start, _)) = ogeom_algo::edge_vertices(model, edge)? else {
7914 ogeom_bail!(Construction, "a profile edge has no vertices");
7915 };
7916 let Some(data) = model.node(&start).and_then(|n| n.data().as_vertex()) else {
7917 ogeom_bail!(Construction, "a profile vertex holds no data");
7918 };
7919 corner_flat.push(flat(data.point));
7920 }
7921 let make_corners = |model: &mut Model, station: usize| -> OgeomResult<Vec<Shape>> {
7922 let mut out = Vec::with_capacity(corner_flat.len());
7923 for ab in &corner_flat {
7924 out.push(ogeom_algo::make_vertex(model, place(station, *ab)?).shape);
7925 }
7926 Ok(out)
7927 };
7928 let mut corners_at: Vec<Option<Vec<Shape>>> = vec![None; stations.len()];
7932 if ring {
7933 let mut worst = 0.0_f64;
7941 for ab in &corner_flat {
7942 worst = worst.max(place(last, *ab)?.distance(place(0, *ab)?));
7943 for &k in &kinks {
7944 worst = worst.max(place(k, *ab)?.distance(place(k + 1, *ab)?));
7945 }
7946 }
7947 if worst > tolerance.max(tol.confusion() * 100.0) {
7948 ogeom_bail!(
7949 Construction,
7950 "a skew-cornered ring's sections do not meet on \
7951 their mitres; the out-of-plane corner's frame law \
7952 is still owed; see docs/CAPABILITIES.md, offset.sweeps"
7953 );
7954 }
7955 let set = make_corners(model, 0)?;
7956 if worst > tol.confusion() {
7957 for v in &set {
7958 model.widen(v, ogeom_core::Tolerance::new(worst * 2.0)?)?;
7959 }
7960 }
7961 corners_at[0] = Some(set.clone());
7962 corners_at[last] = Some(set);
7963 } else {
7964 corners_at[0] = Some(make_corners(model, 0)?);
7965 corners_at[last] = Some(make_corners(model, last)?);
7966 }
7967 for &k in &kinks {
7968 let set = make_corners(model, k)?;
7969 corners_at[k] = Some(set.clone());
7970 corners_at[k + 1] = Some(set);
7971 }
7972
7973 let hint_flat = {
7975 let mut a = 0.0;
7976 let mut b = 0.0;
7977 for (fa, fb) in &corner_flat {
7978 a += fa;
7979 b += fb;
7980 }
7981 #[allow(clippy::cast_precision_loss)]
7982 let n = count as f64;
7983 (a / n, b / n)
7984 };
7985
7986 let mut bottoms = Vec::with_capacity(count);
7987 let mut tops = Vec::with_capacity(count);
7988 let mut run_rails: Vec<(Option<Shape>, Option<Shape>)> = vec![(None, None); runs.len()];
7992 for (ei, edge) in edges.iter().enumerate() {
7993 let (curve, range) = spine_curve_of(model, edge)?;
7994 let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
7995 const ALONG_EDGE: usize = 8;
7996 let edge_flat = |f: f64| -> OgeomResult<(f64, f64)> {
7997 let t = if reversed {
7998 range.1 - (range.1 - range.0) * f
7999 } else {
8000 range.0 + (range.1 - range.0) * f
8001 };
8002 Ok(flat(curve.point_at(t, tol)?))
8003 };
8004 let next = (ei + 1) % count;
8005 let mut bottom: Option<Shape> = None;
8006 let mut top: Option<Shape> = None;
8007 for (ri, &(rs, re)) in runs.iter().enumerate() {
8008 let held = std::cell::RefCell::default();
8011 let skin = if plain((rs, re)) {
8012 Skin::swept(
8013 |f, s| along_run((rs, re), s, edge_flat(f)?),
8014 fractions(ALONG_EDGE),
8015 (rs, re),
8016 false,
8017 )?
8018 } else if straight(rs, re) {
8019 Skin::columns(
8020 |f| straight_column((rs, re), edge_flat(f)?),
8021 fractions(ALONG_EDGE),
8022 false,
8023 false,
8024 false,
8025 )?
8026 } else {
8027 Skin::swept(
8028 |f, s| rerowed_point((rs, re), &held, f, edge_flat(f)?, s),
8029 fractions(ALONG_EDGE),
8030 (rs, re),
8031 false,
8032 )?
8033 };
8034 let shared_start = shares_start(ri).then_some(()).and(top.as_ref());
8035 let shared_end = shares_end(ri).then_some(()).and(bottom.as_ref());
8036 let mid_i = usize::midpoint(rs, re);
8037 let hint = {
8038 let x = normals[mid_i];
8039 let y = stations[mid_i].tangent.cross(x);
8040 stations[mid_i].at + x * hint_flat.0 + y * hint_flat.1
8041 };
8042 let (Some(from), Some(to)) = (&corners_at[rs], &corners_at[re]) else {
8043 ogeom_bail!(Construction, "a run boundary has no corners");
8044 };
8045 let (first_rail0, prev_rail1) = run_rails[ri].clone();
8046 let shared_rail0 = if ei > 0 { prev_rail1 } else { None };
8047 let shared_rail1 = if ei + 1 == count && count > 1 {
8048 first_rail0.clone()
8049 } else {
8050 None
8051 };
8052 let strip = skinned_strip(
8053 model,
8054 &skin,
8055 (&from[ei], &from[next], &to[ei], &to[next]),
8056 [
8057 shared_start,
8058 shared_end,
8059 shared_rail0.as_ref(),
8060 shared_rail1.as_ref(),
8061 ],
8062 hint,
8063 hole,
8064 tolerance,
8065 tol,
8066 )?;
8067 run_rails[ri] = (
8068 if ei == 0 {
8069 Some(strip.rail0.clone())
8070 } else {
8071 first_rail0
8072 },
8073 Some(strip.rail1.clone()),
8074 );
8075 faces.push(strip.face.clone());
8076 if bottom.is_none() {
8077 bottom = Some(strip.bottom);
8078 }
8079 top = Some(strip.top);
8080 }
8081 let (Some(bottom), Some(top)) = (bottom, top) else {
8082 ogeom_bail!(Construction, "the sweep produced no strip");
8083 };
8084 bottoms.push(bottom);
8085 tops.push(top);
8086 }
8087 ends.push(LoopWall::Chain { bottoms, tops });
8088 }
8089 }
8090
8091 for end in 0..if ring { 0 } else { 2 } {
8096 let (at, outward) = if end == 0 {
8097 (stations[0].at, -stations[0].tangent)
8098 } else {
8099 (stations[last].at, stations[last].tangent)
8100 };
8101 let cap_plane = Plane::through(at, Direction::new(outward, tol)?);
8102 let mut loop_edges: Vec<Vec<Shape>> = Vec::with_capacity(ends.len());
8103 for wall in &ends {
8104 loop_edges.push(match wall {
8105 LoopWall::Ring { ring0, ring1 } => {
8106 vec![if end == 0 {
8107 ring0.clone()
8108 } else {
8109 ring1.clone()
8110 }]
8111 }
8112 LoopWall::Chain { bottoms, tops } => {
8113 if end == 0 {
8114 bottoms.clone()
8115 } else {
8116 tops.clone()
8117 }
8118 }
8119 });
8120 }
8121 let mut reach = 1.0_f64;
8122 for edges in &loop_edges {
8123 for edge in edges {
8124 let (curve, range) = spine_curve_of(model, edge)?;
8125 for t in 0..8 {
8126 let p =
8127 curve.point_at(range.0 + (range.1 - range.0) * f64::from(t) / 8.0, tol)?;
8128 reach = reach.max(p.distance(at) * 2.0);
8129 }
8130 }
8131 }
8132 let cap_surface: SurfaceGeometry =
8133 PlaneSurface::over(cap_plane, (-reach, reach), (-reach, reach))?.into();
8134 let mut wires: Vec<Shape> = Vec::with_capacity(loop_edges.len());
8137 for (li, edges) in loop_edges.iter().enumerate() {
8138 let ring = walked_about(model, edges, outward, li == 0, tol)?;
8139 wires.push(ogeom_algo::make_wire(model, &ring, tol)?.shape);
8140 }
8141 let face = ogeom_algo::make_face(model, cap_surface.clone(), &wires, tol)?.shape;
8142 let cap_id = {
8143 let Some(node) = model.node(&face) else {
8144 ogeom_bail!(Dangling, "the cap just built is not in this model");
8145 };
8146 let ogeom_topo::NodeData::Face(data) = node.data() else {
8147 ogeom_bail!(Construction, "the cap holds no face data");
8148 };
8149 data.surface
8150 };
8151 let frame = cap_plane.frame();
8152 for edges in &loop_edges {
8153 for edge in edges {
8154 let (curve, range) = spine_curve_of(model, edge)?;
8155 let ogeom_geom::Curve::BSpline(bs) = &curve else {
8156 ogeom_bail!(Construction, "a swept ring is not a spline");
8157 };
8158 let control2: Vec<Point2> = bs
8161 .control_points()
8162 .iter()
8163 .map(|w| {
8164 let local = frame.to_local(w.point());
8165 Point2::new(local.x, local.y)
8166 })
8167 .collect();
8168 let pcurve: ogeom_geom::PlanarCurve =
8169 ogeom_geom::BSpline2d::new(bs.knots().clone(), control2, tol)?.into();
8170 ogeom_algo::attach_pcurve(
8171 model,
8172 edge,
8173 pcurve,
8174 cap_id,
8175 ogeom_topo::Location::identity(),
8176 range,
8177 )?;
8178 }
8179 }
8180 faces.push(face);
8181 }
8182
8183 let sewn = sew(model, &faces, tol)?;
8184 let mut built = if ring {
8185 if sewn.shells.is_empty() {
8189 ogeom_bail!(Construction, "the pipe shell did not close");
8190 }
8191 for shell in &sewn.shells {
8192 if !ogeom_algo::is_shell_closed(model, shell)? {
8193 ogeom_bail!(Construction, "the pipe shell did not close");
8194 }
8195 }
8196 let mut ordered = sewn.shells.clone();
8197 let mut sized: Vec<(f64, Shape)> = Vec::with_capacity(ordered.len());
8198 for shell in ordered.drain(..) {
8199 let bound = ogeom_algo::shape_bounds(model, &shell, tol)?;
8200 sized.push((bound.diagonal(), shell));
8201 }
8202 sized.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(core::cmp::Ordering::Equal));
8203 let shells: Vec<Shape> = sized.into_iter().map(|(_, s)| s).collect();
8204 make_solid(model, &shells)?
8205 } else {
8206 if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
8207 if std::env::var_os("OGEOM_DEBUG_SWEEP").is_some() {
8208 eprintln!(
8209 "SWEEP: {} shells, {} free edges from {} faces",
8210 sewn.shells.len(),
8211 sewn.free_edges.len(),
8212 faces.len()
8213 );
8214 for edge in &sewn.free_edges {
8215 let (curve, range) = spine_curve_of(model, edge)?;
8216 let a = curve.point_at(range.0, tol)?;
8217 let b = curve.point_at(range.1, tol)?;
8218 let m = curve.point_at(f64::midpoint(range.0, range.1), tol)?;
8219 let t = model.tolerance_of(edge)?.map_or(0.0, |t| t.get());
8220 eprintln!(
8221 " free ({:.3},{:.3},{:.3}) -> ({:.3},{:.3},{:.3}) via ({:.3},{:.3},{:.3}) tol {t:.2e}",
8222 a.x, a.y, a.z, b.x, b.y, b.z, m.x, m.y, m.z
8223 );
8224 }
8225 }
8226 ogeom_bail!(Construction, "the pipe shell did not close");
8227 }
8228 make_solid(model, std::slice::from_ref(&sewn.shells[0]))?
8229 };
8230 built.history.generate(profile, built.shape.clone());
8231 built.history.generate(spine, built.shape.clone());
8232 Ok(built)
8233}
8234
8235pub(crate) fn bounded_face(model: &mut Model, wire: &Shape, tol: Tolerances) -> OgeomResult<Shape> {
8237 let Some(plane) = ogeom_algo::find_plane(model, wire, tol)? else {
8238 ogeom_bail!(Construction, "a pipe shell sweeps a planar profile");
8239 };
8240 let reach = 1e4_f64;
8241 let surface: SurfaceGeometry =
8242 PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
8243 let face = ogeom_algo::make_face(model, surface, std::slice::from_ref(wire), tol)?.shape;
8244 realized_profile(model, &face, &Transform::IDENTITY, tol)
8245}
8246
8247pub(crate) fn walls_of(
8251 model: &mut Model,
8252 solid: &Shape,
8253 ends: &[(Point, Vector)],
8254 tol: Tolerances,
8255) -> OgeomResult<Shape> {
8256 let mut walls = Vec::new();
8257 for f in explore(model, solid, Filter::OfType(ShapeType::Face))? {
8258 let Some(ogeom_topo::NodeData::Face(data)) = model.node(&f).map(|n| n.data()) else {
8259 continue;
8260 };
8261 let cap = match model.geometry().surface(data.surface) {
8262 Some(SurfaceGeometry::Plane(p)) => {
8263 let placed = p.plane();
8264 ends.iter().any(|(at, n)| {
8265 placed.normal().vector().cross(*n).magnitude() <= tol.angular()
8266 && placed.distance_to(*at) <= tol.confusion() * 100.0
8267 })
8268 }
8269 _ => false,
8270 };
8271 if !cap {
8272 walls.push(f);
8273 }
8274 }
8275 let sewn = sew(model, &walls, tol)?;
8276 Ok(match sewn.shells.as_slice() {
8277 [shell] => shell.clone(),
8278 _ => ogeom_algo::make_compound(model, &sewn.shells)?.shape,
8279 })
8280}
8281
8282#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
8295fn mitred_pieces(
8296 model: &mut Model,
8297 profile: &Shape,
8298 spine: &Shape,
8299 stations: &[SpineStation],
8300 skew: &[(usize, usize)],
8301 ring: bool,
8302 probes: &[(f64, f64)],
8303 tolerance: f64,
8304 tol: Tolerances,
8305) -> OgeomResult<Built> {
8306 if model.kind_of(profile)? == ShapeType::Wire {
8307 if !ogeom_algo::is_wire_closed(model, profile, tol)? {
8311 ogeom_bail!(
8312 Construction,
8313 "a skew corner against a curved leg is mitred by fusing solid \
8314 pieces; an open wire bounds no face to sweep round it"
8315 );
8316 }
8317 let face = bounded_face(model, profile, tol)?;
8318 let solid = mitred_pieces(
8319 model, &face, spine, stations, skew, ring, probes, tolerance, tol,
8320 )?
8321 .shape;
8322 let ends: Vec<(Point, Vector)> = if ring {
8323 Vec::new()
8324 } else {
8325 vec![
8326 (stations[0].at, stations[0].tangent),
8327 (
8328 stations[stations.len() - 1].at,
8329 stations[stations.len() - 1].tangent,
8330 ),
8331 ]
8332 };
8333 let shape = walls_of(model, &solid, &ends, tol)?;
8334 let mut history = History::new();
8335 history.generate(profile, shape.clone());
8336 history.generate(spine, shape.clone());
8337 return Ok(Built::new(shape, history));
8338 }
8339 if model.kind_of(profile)? != ShapeType::Face {
8340 ogeom_bail!(Construction, "a pipe shell sweeps a planar wire or face");
8341 }
8342 let edges: Vec<Shape> = match model.kind_of(spine)? {
8343 ShapeType::Edge => vec![spine.clone()],
8344 _ => model.ordered_children_of(spine)?,
8345 };
8346 let normals = rmf_normals(stations);
8347 let reach_out = probes
8348 .iter()
8349 .map(|(a, b)| a.hypot(*b))
8350 .fold(0.0_f64, f64::max);
8351
8352 struct Split {
8356 edge_before: usize,
8357 at: Point,
8358 before: Vector,
8359 after: Vector,
8360 frame_after: Vector,
8361 run_on: f64,
8362 }
8363 let mut splits: Vec<Split> = Vec::with_capacity(skew.len());
8364 for &(k, next) in skew {
8365 let (before, after) = (stations[k].tangent, stations[next].tangent);
8366 let turn = before.dot(after).clamp(-1.0, 1.0).acos();
8367 let half = (turn * 0.5).cos();
8368 if half < 0.05 {
8369 ogeom_bail!(
8370 Construction,
8371 "the spine all but doubles back at a corner; a mitre there \
8372 runs off to infinity"
8373 );
8374 }
8375 splits.push(Split {
8376 edge_before: stations[k].edge,
8377 at: stations[k].at,
8378 before,
8379 after,
8380 frame_after: normals[next],
8381 run_on: reach_out * ((turn * 0.5).tan() * 1.5 + 0.1),
8385 });
8386 }
8387 splits.sort_by_key(|s| s.edge_before);
8388
8389 let count = edges.len();
8392 let mut pieces: Vec<(Vec<usize>, Option<usize>, Option<usize>)> = Vec::new();
8393 if ring {
8394 for (i, split) in splits.iter().enumerate() {
8395 let next = &splits[(i + 1) % splits.len()];
8396 let mut run = Vec::new();
8397 let mut e = (split.edge_before + 1) % count;
8398 loop {
8399 run.push(e);
8400 if e == next.edge_before {
8401 break;
8402 }
8403 e = (e + 1) % count;
8404 }
8405 pieces.push((run, Some(i), Some((i + 1) % splits.len())));
8406 }
8407 } else {
8408 let mut first = 0;
8409 for (i, split) in splits.iter().enumerate() {
8410 pieces.push((
8411 (first..=split.edge_before).collect(),
8412 i.checked_sub(1),
8413 Some(i),
8414 ));
8415 first = split.edge_before + 1;
8416 }
8417 pieces.push(((first..count).collect(), splits.len().checked_sub(1), None));
8418 }
8419
8420 let x0 = normals[0];
8421 let start_frame = Frame::new(
8422 stations[0].at,
8423 Direction::new(stations[0].tangent, tol)?,
8424 Direction::new(x0, tol)?,
8425 tol,
8426 )?;
8427 let mut blocks: Vec<Shape> = Vec::with_capacity(splits.len());
8433 for split in &splits {
8434 let n = (split.before + split.after) / (split.before + split.after).magnitude();
8435 let normal = Direction::new(n, tol)?;
8436 let plane = Plane::through(split.at, normal);
8437 let reach = (split.run_on + reach_out) * 4.0;
8438 let frame = plane.frame();
8439 let (u, v) = (frame.x().vector(), frame.y().vector());
8440 let corners: Vec<Point> = [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
8441 .iter()
8442 .map(|(a, b)| split.at + u * (a * reach) + v * (b * reach))
8443 .collect();
8444 let wire = ogeom_algo::make_polygon(model, &corners, true, tol)?.shape;
8445 let edges = explore(model, &wire, Filter::OfType(ShapeType::Edge))?;
8446 let surface: SurfaceGeometry = PlaneSurface::over(
8447 plane,
8448 (-reach * 2.0, reach * 2.0),
8449 (-reach * 2.0, reach * 2.0),
8450 )?
8451 .into();
8452 let base = ogeom_algo::make_face_with_pcurves(model, surface, &[edges], tol)?.shape;
8453 let block = ogeom_algo::make_prism(model, &base, n * (reach * 2.0), tol)?.shape;
8454 blocks.push(block);
8455 }
8456
8457 let mut joined: Vec<Shape> = Vec::new();
8458 for (run, start, end) in pieces {
8459 let mut wire_edges: Vec<Shape> = Vec::new();
8460 let traversal = |model: &Model, e: usize, at_start: bool| -> OgeomResult<Shape> {
8461 let Some((a, b)) = ogeom_algo::edge_vertices(model, &edges[e])? else {
8464 ogeom_bail!(Construction, "a spine edge has no vertices");
8465 };
8466 Ok(if at_start { a } else { b })
8467 };
8468 if let Some(i) = start {
8469 let split = &splits[i];
8470 let far = ogeom_algo::make_vertex(model, split.at - split.after * split.run_on).shape;
8471 let near = traversal(model, run[0], true)?;
8472 let line: ogeom_geom::Curve =
8473 LineCurve::segment(split.at - split.after * split.run_on, split.at, tol)?.into();
8474 let domain = line.domain();
8475 wire_edges
8476 .push(ogeom_algo::make_edge_between(model, line, domain, &far, &near, tol)?.shape);
8477 }
8478 wire_edges.extend(run.iter().map(|&e| edges[e].clone()));
8479 if let Some(i) = end {
8480 let split = &splits[i];
8481 let near = traversal(model, run[run.len() - 1], false)?;
8482 let far = ogeom_algo::make_vertex(model, split.at + split.before * split.run_on).shape;
8483 let line: ogeom_geom::Curve =
8484 LineCurve::segment(split.at, split.at + split.before * split.run_on, tol)?.into();
8485 let domain = line.domain();
8486 wire_edges
8487 .push(ogeom_algo::make_edge_between(model, line, domain, &near, &far, tol)?.shape);
8488 }
8489 let sub_spine = ogeom_algo::make_wire(model, &wire_edges, tol)?.shape;
8490 let placed = match start {
8495 None => profile.clone(),
8496 Some(i) => {
8497 let split = &splits[i];
8498 let target = Frame::new(
8499 split.at - split.after * split.run_on,
8500 Direction::new(split.after, tol)?,
8501 Direction::new(split.frame_after, tol)?,
8502 tol,
8503 )?;
8504 let motion = Transform::from_frame(&target) * Transform::to_frame(&start_frame);
8505 realized_profile(model, profile, &motion, tol)?
8506 }
8507 };
8508 let mut piece = make_pipe_shell(model, &placed, &sub_spine, false, tolerance, tol)?.shape;
8509 if model.kind_of(&piece)? != ShapeType::Solid {
8510 ogeom_bail!(Construction, "a mitred piece did not sweep into a solid");
8511 }
8512 if let Some(i) = start {
8513 piece = ogeom_bool::common(model, &piece, &blocks[i], tol)?.shape;
8514 }
8515 if let Some(i) = end {
8516 piece = ogeom_bool::cut(model, &piece, &blocks[i], tol)?.shape;
8517 }
8518 joined.push(piece);
8519 }
8520 let result = fuse_in_order(model, joined, tol)?;
8521 let Some(shape) = result else {
8522 ogeom_bail!(Construction, "the spine produced no piece to sweep");
8523 };
8524 let mut history = History::new();
8525 history.generate(profile, shape.clone());
8526 for edge in &edges {
8527 history.generate(edge, shape.clone());
8528 }
8529 Ok(Built::new(shape, history))
8530}
8531
8532fn realized_profile(
8535 model: &mut Model,
8536 profile: &Shape,
8537 motion: &Transform,
8538 tol: Tolerances,
8539) -> OgeomResult<Shape> {
8540 realized_profile_wound(model, profile, motion, None, tol)
8541}
8542
8543fn realized_profile_wound(
8546 model: &mut Model,
8547 profile: &Shape,
8548 motion: &Transform,
8549 about: Option<Vector>,
8550 tol: Tolerances,
8551) -> OgeomResult<Shape> {
8552 let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
8553 ogeom_bail!(Construction, "a pipe shell sweeps a planar profile");
8554 };
8555 let moved_plane = Plane::through(
8556 motion.apply(plane.origin()),
8557 Direction::new(motion.apply_vector(plane.normal().vector()), tol)?,
8558 );
8559 let mut wires = realized_rings(model, profile, motion, tol)?;
8560 if let Some(axis) = about {
8561 for (k, ring) in wires.iter_mut().enumerate() {
8562 let turning = ring_turning(model, ring, axis, tol)?;
8563 if (turning > 0.0) != (k == 0) {
8564 *ring = ring.iter().rev().map(Shape::reversed).collect();
8565 }
8566 }
8567 }
8568 let reach = 1e4_f64;
8569 let surface: SurfaceGeometry =
8570 PlaneSurface::over(moved_plane, (-reach, reach), (-reach, reach))?.into();
8571 Ok(ogeom_algo::make_face_with_pcurves(model, surface, &wires, tol)?.shape)
8572}
8573
8574fn realized_rings(
8578 model: &mut Model,
8579 profile: &Shape,
8580 motion: &Transform,
8581 tol: Tolerances,
8582) -> OgeomResult<Vec<Vec<Shape>>> {
8583 use ogeom_geom::Transformable as _;
8584 let mut vertices: ogeom_core::FastMap<ogeom_topo::TShapeId, Shape> =
8585 ogeom_core::FastMap::default();
8586 let mut edge_copies: ogeom_core::FastMap<ogeom_topo::TShapeId, Shape> =
8587 ogeom_core::FastMap::default();
8588 let mut wires: Vec<Vec<Shape>> = Vec::new();
8589 for wire in explore(model, profile, Filter::OfType(ShapeType::Wire))? {
8590 let mut ring = Vec::new();
8591 for edge in model.ordered_children_of(&wire)? {
8592 let copy = match edge_copies.get(&edge.node()) {
8593 Some(done) => done.clone(),
8594 None => {
8595 let (curve, range) = spine_curve_of(model, &edge)?;
8596 let placed = curve.transformed(&edge.transform(model.datums())?, tol)?;
8597 let moved = placed.transformed(motion, tol)?;
8598 let own = if edge.orientation() == ogeom_topo::Orientation::Reversed {
8601 edge.reversed()
8602 } else {
8603 edge.clone()
8604 };
8605 let Some((a, b)) = ogeom_algo::edge_vertices(model, &own)? else {
8606 ogeom_bail!(Construction, "a profile edge has no vertices");
8607 };
8608 let mut ends = Vec::with_capacity(2);
8609 for v in [a, b] {
8610 let key = v.node();
8611 let held = match vertices.get(&key) {
8612 Some(done) => done.clone(),
8613 None => {
8614 let Some(data) = model.node(&v).and_then(|n| n.data().as_vertex())
8615 else {
8616 ogeom_bail!(Construction, "a profile vertex holds no data");
8617 };
8618 let at = v.transform(model.datums())?.apply(data.point);
8619 let fresh = ogeom_algo::make_vertex(model, motion.apply(at)).shape;
8620 vertices.insert(key, fresh.clone());
8621 fresh
8622 }
8623 };
8624 ends.push(held);
8625 }
8626 let fresh = ogeom_algo::make_edge_between(
8627 model, moved, range, &ends[0], &ends[1], tol,
8628 )?
8629 .shape;
8630 edge_copies.insert(edge.node(), fresh.clone());
8631 fresh
8632 }
8633 };
8634 ring.push(if edge.orientation() == ogeom_topo::Orientation::Reversed {
8635 copy.reversed()
8636 } else {
8637 copy
8638 });
8639 }
8640 wires.push(ring);
8641 }
8642 Ok(wires)
8643}
8644
8645fn walked_about(
8650 model: &Model,
8651 ring: &[Shape],
8652 axis: Vector,
8653 outer: bool,
8654 tol: Tolerances,
8655) -> OgeomResult<Vec<Shape>> {
8656 let turn = ring_turning(model, ring, axis, tol)?;
8657 Ok(if (turn > 0.0) == outer {
8658 ring.to_vec()
8659 } else {
8660 walked_back(ring)
8661 })
8662}
8663
8664fn walked_back(ring: &[Shape]) -> Vec<Shape> {
8667 ring.iter().rev().map(Shape::reversed).collect()
8668}
8669
8670fn ring_turning(model: &Model, ring: &[Shape], axis: Vector, tol: Tolerances) -> OgeomResult<f64> {
8673 let mut points: Vec<Point> = Vec::new();
8674 for edge in ring {
8675 let (curve, range) = spine_curve_of(model, edge)?;
8676 let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
8677 for i in 0..32 {
8678 let f = f64::from(i) / 32.0;
8679 let t = if reversed {
8680 range.1 - (range.1 - range.0) * f
8681 } else {
8682 range.0 + (range.1 - range.0) * f
8683 };
8684 points.push(curve.point_at(t, tol)?);
8685 }
8686 }
8687 let Some(&origin) = points.first() else {
8688 return Ok(0.0);
8689 };
8690 let n = points.len();
8691 Ok((0..n)
8692 .map(|i| {
8693 (points[i] - origin)
8694 .cross(points[(i + 1) % n] - origin)
8695 .dot(axis)
8696 })
8697 .sum())
8698}
8699
8700pub(crate) fn spine_curve_of(
8701 model: &Model,
8702 edge: &Shape,
8703) -> OgeomResult<(ogeom_geom::Curve, (f64, f64))> {
8704 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
8705 ogeom_bail!(Construction, "an edge holds no data");
8706 };
8707 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
8708 ogeom_bail!(Construction, "an edge has no curve");
8709 };
8710 let Some(geometry) = model.geometry().curve(*curve) else {
8711 ogeom_bail!(Dangling, "curve is not in this model");
8712 };
8713 Ok((geometry.clone(), *range))
8714}
8715
8716pub(crate) fn placed_path(model: &mut Model, path: &Shape, tol: Tolerances) -> OgeomResult<Shape> {
8723 let kind = model.kind_of(path)?;
8724 let edges = match kind {
8725 ShapeType::Edge => vec![path.clone()],
8726 ShapeType::Wire => model.ordered_children_of(path)?,
8727 _ => return Ok(path.clone()),
8728 };
8729 let curve_placed = |e: &Shape| {
8730 model
8731 .node(e)
8732 .and_then(|n| n.data().as_edge())
8733 .and_then(ogeom_topo::EdgeData::curve3d)
8734 .and_then(EdgeRepr::location)
8735 .is_some_and(|l| !l.is_identity())
8736 };
8737 if edges
8738 .iter()
8739 .all(|e| e.location().is_identity() && !curve_placed(e))
8740 {
8741 return Ok(path.clone());
8742 }
8743 let mut twins: Vec<(Shape, Shape)> = Vec::new();
8745 let mut twin_of = |model: &mut Model, vertex: &Shape| -> OgeomResult<Shape> {
8746 if let Some((_, twin)) = twins.iter().find(|(v, _)| v.is_same(vertex)) {
8747 return Ok(twin.clone());
8748 }
8749 let Some(data) = model.node(vertex).and_then(|n| n.data().as_vertex()) else {
8750 ogeom_bail!(Construction, "a spine vertex holds no point");
8751 };
8752 let at = vertex.transform(model.datums())?.apply(data.point);
8753 let held = data.tolerance;
8754 let twin = make_vertex(model, at).shape;
8755 model.widen(&twin, held)?;
8756 twins.push((vertex.clone(), twin.clone()));
8757 Ok(twin)
8758 };
8759 let mut placed: Vec<Shape> = Vec::with_capacity(edges.len());
8760 for edge in &edges {
8761 let Some((curve, range)) = ogeom_algo::placed_edge_curve(model, edge, tol)? else {
8762 ogeom_bail!(Construction, "an edge has no curve");
8763 };
8764 let Some((first, last)) = edge_vertices(model, edge)? else {
8765 ogeom_bail!(Construction, "a spine edge has no vertices");
8766 };
8767 let (a, b) = (twin_of(model, &first)?, twin_of(model, &last)?);
8768 let built = if edge.orientation() == ogeom_topo::Orientation::Reversed {
8769 make_edge_between(model, curve, range, &b, &a, tol)?
8770 .shape
8771 .reversed()
8772 } else {
8773 make_edge_between(model, curve, range, &a, &b, tol)?.shape
8774 };
8775 if let Some(held) = model.tolerance_of(edge)? {
8776 model.widen(&built, held)?;
8777 }
8778 placed.push(built);
8779 }
8780 if kind == ShapeType::Edge {
8781 return Ok(placed.swap_remove(0));
8782 }
8783 Ok(ogeom_algo::make_wire(model, &placed, tol)?.shape)
8784}
8785
8786#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
8804fn closed_pipe_shell(
8805 model: &mut Model,
8806 profile: &Shape,
8807 spine: &Shape,
8808 mut stations: Vec<SpineStation>,
8809 law: &PipeLaw<'_>,
8810 twist: &evolving::Ramp,
8811 tolerance: f64,
8812 tol: Tolerances,
8813) -> OgeomResult<Built> {
8814 let frenet = matches!(law, PipeLaw::Frenet);
8815 let classic = matches!(law, PipeLaw::RotationMinimizing | PipeLaw::Frenet);
8816 let Some(home) = stations.pop() else {
8818 ogeom_bail!(Construction, "a closed spine needs room to turn");
8819 };
8820 if stations.len() < 3 {
8821 ogeom_bail!(Construction, "a closed spine needs room to turn");
8822 }
8823 for i in 0..stations.len() {
8826 let next = &stations[(i + 1) % stations.len()];
8827 if stations[i].tangent.dot(next.tangent) < 0.9 {
8828 ogeom_bail!(
8829 Construction,
8830 "a closed spine turns too sharply between two of its stations to skin"
8831 );
8832 }
8833 }
8834
8835 let loops: Vec<Shape> = match model.kind_of(profile)? {
8836 ShapeType::Face => explore(model, profile, Filter::OfType(ShapeType::Wire))?,
8837 ShapeType::Wire => vec![profile.clone()],
8838 other => ogeom_bail!(
8839 Construction,
8840 "a pipe shell sweeps a planar wire or face, not a {other:?}"
8841 ),
8842 };
8843 let profile_loop = &loops[0];
8846 let edges = model.ordered_children_of(profile_loop)?;
8847 let smooth = edges.len() == 1
8848 && ogeom_algo::edge_vertices(model, &edges[0])?.is_some_and(|(a, b)| a.is_same(&b));
8849 let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
8850 ogeom_bail!(Construction, "a pipe shell sweeps a planar profile");
8851 };
8852 let t0 = stations[0].tangent;
8853 profile_square_at_start(model, &plane, spine, stations[0].at, tol)?;
8854
8855 let mut normals: Vec<Vector> = if !classic {
8862 law_normals(model, &stations, law, tolerance, tol)?
8863 } else if frenet {
8864 frenet_normals_closed(&stations, tol)?
8871 } else {
8872 let mut extended = stations.clone();
8873 extended.push(stations[0]);
8874 let carried = rmf_normals(&extended);
8875 let (n0, n_home) = (carried[0], carried[carried.len() - 1]);
8876 let twist = (n0.cross(n_home).dot(t0)).atan2(n0.dot(n_home));
8877 let mut lengths = vec![0.0_f64];
8878 for pair in extended.windows(2) {
8879 let last = lengths[lengths.len() - 1];
8880 lengths.push(last + pair[0].at.distance(pair[1].at));
8881 }
8882 let total = lengths[lengths.len() - 1];
8883 carried
8884 .iter()
8885 .take(stations.len())
8886 .enumerate()
8887 .map(|(i, n)| {
8888 let phi = -twist * lengths[i] / total;
8889 let t = extended[i].tangent;
8890 *n * phi.cos() + t.cross(*n) * phi.sin()
8891 })
8892 .collect()
8893 };
8894 for (n, station) in normals.iter_mut().zip(&stations) {
8895 let v = *n - station.tangent * n.dot(station.tangent);
8897 *n = v / v.magnitude();
8898 }
8899
8900 let x0 = normals[0];
8901 let origin = stations[0].at;
8902 let (round, round_normals) = {
8905 let mut round = stations.clone();
8906 round.push(SpineStation {
8907 tangent: stations[0].tangent,
8908 ..home
8909 });
8910 let mut normals = normals.clone();
8911 normals.push(normals[0]);
8912 (round, normals)
8913 };
8914 let walk = SpineWalk {
8915 curves: walk_curves(model, spine)?,
8916 stations: &round,
8917 normals: &round_normals,
8918 };
8919 let guide = guide_curves(model, law)?;
8920 let lengths = if twist.is_level(0.0) {
8921 None
8922 } else {
8923 Some(evolving::SpineLengths::of(&walk.curves, tol)?)
8924 };
8925 let home = round.len() - 1;
8926 let end = twist.end();
8930 let turned = (end - (end / core::f64::consts::TAU).round() * core::f64::consts::TAU).abs()
8931 > tol.angular();
8932 let share = |s: f64| -> OgeomResult<f64> {
8935 let Some(lengths) = &lengths else {
8936 return Ok(0.0);
8937 };
8938 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
8939 let station = (s.fract() == 0.0).then(|| (s as usize) % home);
8940 let (edge, at_t) = match station {
8941 Some(0) => return Ok(if turned && s > 0.0 { 1.0 } else { 0.0 }),
8942 Some(i) => (round[i].edge, round[i].t),
8943 None => {
8944 let j = s.floor();
8945 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
8946 let place = walk.place_between(j as usize, s - j);
8947 place
8948 }
8949 };
8950 lengths.fraction(&walk.curves, edge, at_t, tol)
8951 };
8952 let carried = |s: f64, (a, b): (f64, f64)| -> OgeomResult<Point> {
8956 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
8957 let station = (s.fract() == 0.0).then(|| (s as usize) % home);
8958 let (p, t, x) = if let Some(i) = station {
8959 let station = &round[i];
8960 (station.at, station.tangent, round_normals[i])
8961 } else {
8962 let (p, t, x) = walk.frame_at(s, (0, home), tol)?;
8963 let x = if classic {
8964 x
8965 } else {
8966 let here = SpineStation {
8967 at: p,
8968 tangent: t,
8969 ..round[0]
8970 };
8971 law_normals_on(&guide, &[here], law, tolerance, tol)?[0]
8972 };
8973 (p, t, x)
8974 };
8975 let (a, b) = if lengths.is_some() {
8976 let (sin, cos) = twist.at(share(s)?).sin_cos();
8977 (a * cos - b * sin, a * sin + b * cos)
8978 } else {
8979 (a, b)
8980 };
8981 Ok(p + x * a + t.cross(x) * b)
8982 };
8983 let mut shells: Vec<Shape> = Vec::with_capacity(loops.len());
8984 for (li, wire) in loops.iter().enumerate() {
8985 let wire_edges = model.ordered_children_of(wire)?;
8986 let wire_smooth = wire_edges.len() == 1
8987 && ogeom_algo::edge_vertices(model, &wire_edges[0])?
8988 .is_some_and(|(a, b)| a.is_same(&b));
8989 let shell = if turned {
8990 turned_loop_shell(
8991 model,
8992 &wire_edges,
8993 wire_smooth,
8994 &round,
8995 &carried,
8996 &share,
8997 (origin, x0),
8998 tolerance,
8999 tol,
9000 )?
9001 } else {
9002 closed_loop_shell(
9003 model,
9004 wire,
9005 &wire_edges,
9006 wire_smooth,
9007 &round,
9008 &carried,
9009 (origin, x0),
9010 tolerance,
9011 tol,
9012 )?
9013 };
9014 let enclosed = shell_signed_volume(model, &shell, tol)?;
9018 let outward = enclosed > 0.0;
9019 shells.push(if outward == (li == 0) {
9020 shell
9021 } else {
9022 shell.reversed()
9023 });
9024 }
9025 let mut built = make_solid(model, &shells)?;
9026 built.history.generate(profile, built.shape.clone());
9027 built.history.generate(spine, built.shape.clone());
9028 let _ = (smooth, profile_loop, edges);
9029 Ok(built)
9030}
9031
9032fn shell_signed_volume(model: &Model, shell: &Shape, tol: Tolerances) -> OgeomResult<f64> {
9035 Ok(ogeom_mesh::triangulate(model, shell, ogeom_mesh::Deflection::default(), tol)?.volume())
9036}
9037
9038fn shell_stations(model: &Model, spine: &Shape, tol: Tolerances) -> OgeomResult<Vec<SpineStation>> {
9041 let edges: Vec<Shape> = match model.kind_of(spine)? {
9042 ShapeType::Edge => vec![spine.clone()],
9043 ShapeType::Wire => model.ordered_children_of(spine)?,
9044 other => ogeom_bail!(
9045 Construction,
9046 "a pipe shell runs along an edge or a wire, not a {other:?}"
9047 ),
9048 };
9049 if edges.is_empty() {
9050 ogeom_bail!(Construction, "the spine has no edge to run along");
9051 }
9052 let mut stations: Vec<SpineStation> = Vec::new();
9053 for (ei, edge) in edges.iter().enumerate() {
9054 let (curve, range) = {
9055 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
9056 ogeom_bail!(Construction, "a spine edge holds no data");
9057 };
9058 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
9059 ogeom_bail!(Construction, "a spine edge has no curve");
9060 };
9061 let Some(geometry) = model.geometry().curve(*curve) else {
9062 ogeom_bail!(Dangling, "curve is not in this model");
9063 };
9064 (geometry.clone(), *range)
9065 };
9066 let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
9067 let turning = {
9071 let mut sum = 0.0_f64;
9072 let mut last: Option<Vector> = None;
9073 for i in 0..=16 {
9074 let t = range.0 + (range.1 - range.0) * f64::from(i) / 16.0;
9075 let d = curve.d1_at(t, tol)?;
9076 let m = d.magnitude();
9077 if m <= tol.confusion() {
9078 continue;
9079 }
9080 let u = d / m;
9081 if let Some(prev) = last {
9082 sum += prev.dot(u).clamp(-1.0, 1.0).acos() * 16.0 / 16.0;
9083 }
9084 last = Some(u);
9085 }
9086 sum
9087 };
9088 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
9089 let count = (turning / (core::f64::consts::TAU / 64.0)).ceil().max(8.0) as usize;
9090 for i in 0..=count {
9091 #[allow(clippy::cast_precision_loss)]
9092 let f = (i as f64) / (count as f64);
9093 let t = if reversed {
9094 range.1 - (range.1 - range.0) * f
9095 } else {
9096 range.0 + (range.1 - range.0) * f
9097 };
9098 let p = curve.point_at(t, tol)?;
9099 let d = curve.d1_at(t, tol)?;
9100 let m = d.magnitude();
9101 if m <= tol.confusion() {
9102 ogeom_bail!(Construction, "the spine is degenerate at {t}");
9103 }
9104 let tangent = if reversed { -(d / m) } else { d / m };
9105 if let Some(prev) = stations.last()
9106 && prev.edge == ei
9107 && prev.at.distance(p) <= tol.confusion()
9108 && prev.tangent.cross(tangent).magnitude() <= tol.angular()
9109 && prev.tangent.dot(tangent) > 0.0
9110 {
9111 continue;
9112 }
9113 stations.push(SpineStation {
9119 at: p,
9120 tangent,
9121 edge: ei,
9122 t,
9123 });
9124 }
9125 }
9126 if stations.len() < 2 {
9127 ogeom_bail!(Construction, "the spine collapses to a point");
9128 }
9129 Ok(stations)
9130}
9131
9132fn frenet_normals(stations: &[SpineStation], tol: Tolerances) -> OgeomResult<Vec<Vector>> {
9137 let mut normals: Vec<Option<Vector>> = Vec::with_capacity(stations.len());
9138 for i in 0..stations.len() {
9139 let (before, after) = (
9140 &stations[i.saturating_sub(1)],
9141 &stations[(i + 1).min(stations.len() - 1)],
9142 );
9143 let dt = after.tangent - before.tangent;
9144 let t = stations[i].tangent;
9145 let bend = dt - t * dt.dot(t);
9146 let m = bend.magnitude();
9147 normals.push(if m > tol.angular().max(1e-9) {
9148 Some(bend / m)
9149 } else {
9150 None
9151 });
9152 }
9153 let mut carried: Vec<Vector> = Vec::with_capacity(stations.len());
9156 let mut last: Option<Vector> = None;
9157 for n in &normals {
9158 if let Some(n) = n {
9159 last = Some(*n);
9160 }
9161 carried.push(last.unwrap_or(Vector::new(0.0, 0.0, 0.0)));
9162 }
9163 let mut ahead: Option<Vector> = None;
9164 for i in (0..stations.len()).rev() {
9165 if let Some(n) = normals[i] {
9166 ahead = Some(n);
9167 } else if carried[i].magnitude() < 0.5
9168 && let Some(n) = ahead
9169 {
9170 carried[i] = n;
9171 }
9172 }
9173 if carried.iter().any(|n| n.magnitude() < 0.5) {
9174 ogeom_bail!(
9175 Construction,
9176 "a straight spine has no Frenet frame; use the \
9177 rotation-minimizing default"
9178 );
9179 }
9180 Ok(carried)
9181}
9182
9183fn frenet_normals_closed(stations: &[SpineStation], tol: Tolerances) -> OgeomResult<Vec<Vector>> {
9188 let n = stations.len();
9189 let mut normals: Vec<Option<Vector>> = Vec::with_capacity(n);
9190 for i in 0..n {
9191 let (before, after) = (&stations[(i + n - 1) % n], &stations[(i + 1) % n]);
9192 let dt = after.tangent - before.tangent;
9193 let t = stations[i].tangent;
9194 let bend = dt - t * dt.dot(t);
9195 let m = bend.magnitude();
9196 normals.push(if m > tol.angular().max(1e-9) {
9197 Some(bend / m)
9198 } else {
9199 None
9200 });
9201 }
9202 let Some(first_bend) = normals.iter().position(Option::is_some) else {
9203 ogeom_bail!(
9204 Construction,
9205 "a straight spine has no Frenet frame; use the \
9206 rotation-minimizing default"
9207 );
9208 };
9209 let mut carried: Vec<Vector> = vec![Vector::new(0.0, 0.0, 0.0); n];
9212 let mut last = normals[first_bend].unwrap_or_else(|| unreachable!());
9213 for k in 0..n {
9214 let i = (first_bend + k) % n;
9215 if let Some(bend) = normals[i] {
9216 last = bend;
9217 }
9218 carried[i] = last;
9219 }
9220 Ok(carried)
9221}
9222
9223struct Station {
9228 from: Point,
9230 to: Point,
9231 tangent_in: Vector,
9233 tangent_out: Vector,
9234 turn: Option<(ogeom_math::Axis, f64)>,
9236}
9237
9238pub fn make_evolved(
9279 model: &mut Model,
9280 spine: &Shape,
9281 profile: &Shape,
9282 tol: Tolerances,
9283) -> OgeomResult<Built> {
9284 use ogeom_algo::{make_prism, make_revolution, transformed};
9285
9286 let (wire, capped_by_spine_plane) = match model.kind_of(spine)? {
9287 ShapeType::Face => {
9288 let wires = explore(model, spine, Filter::OfType(ShapeType::Wire))?;
9289 let Some(outer) = wires.first().cloned() else {
9290 ogeom_bail!(Construction, "a face with no wire has no spine to run");
9291 };
9292 (outer, true)
9293 }
9294 ShapeType::Wire => (spine.clone(), false),
9295 other => ogeom_bail!(
9296 Construction,
9297 "a {other:?} is not a spine; sweep along a wire or a planar face"
9298 ),
9299 };
9300
9301 let stations = spine_stations(model, &wire, tol)?;
9302 if stations.is_empty() {
9303 ogeom_bail!(Construction, "a spine with no edges goes nowhere");
9304 }
9305 let normal = spine_normal(&stations, tol)?;
9306 let (profile_origin, profile_normal) = profile_plane(model, profile, tol)?;
9307 if profile_normal.dot(normal.vector()).abs() > tol.angular() {
9308 ogeom_bail!(
9309 Construction,
9310 "the profile's plane must contain the spine's normal, or the \
9311 profile is not square to the spine it travels"
9312 );
9313 }
9314 let start = &stations[0];
9315 if profile_normal.cross(start.tangent_in).magnitude() > tol.angular() {
9316 ogeom_bail!(
9317 Construction,
9318 "the profile's plane must cut the spine across, not run along it: \
9319 the profile is not square to the spine it travels"
9320 );
9321 }
9322 let _ = profile_origin;
9323 let reference = (start.from, start.tangent_in);
9324
9325 let section = profile_face(
9330 model,
9331 profile,
9332 profile_normal,
9333 capped_by_spine_plane.then(|| Plane::through(start.from, normal)),
9334 tol,
9335 )?;
9336
9337 let mut pieces: Vec<Shape> = Vec::new();
9338 for (index, station) in stations.iter().enumerate() {
9339 if index > 0 {
9342 let previous = &stations[index - 1];
9343 if let Some(piece) = corner_piece(
9344 model,
9345 §ion,
9346 reference,
9347 previous.to,
9348 previous.tangent_out,
9349 station.tangent_in,
9350 normal,
9351 tol,
9352 )? {
9353 pieces.push(piece);
9354 }
9355 }
9356 let placed = transformed(
9357 model,
9358 §ion,
9359 station_transform(reference, station.from, station.tangent_in, normal, tol)?,
9360 )?
9361 .shape;
9362 pieces.push(match station.turn {
9363 None => make_prism(model, &placed, station.to - station.from, tol)?.shape,
9364 Some((axis, angle)) => make_revolution(model, &placed, axis, angle, tol)?.shape,
9365 });
9366 }
9367 let last = &stations[stations.len() - 1];
9370 if last.to.distance(start.from) <= tol.confusion()
9371 && let Some(piece) = corner_piece(
9372 model,
9373 §ion,
9374 reference,
9375 last.to,
9376 last.tangent_out,
9377 start.tangent_in,
9378 normal,
9379 tol,
9380 )?
9381 {
9382 pieces.push(piece);
9383 }
9384
9385 let mut history = History::new();
9389 let Some(shape) = fuse_in_order(model, pieces, tol)? else {
9390 ogeom_bail!(Construction, "the spine produced no piece to sweep");
9391 };
9392 history.generate(spine, shape.clone());
9393 history.generate(profile, shape.clone());
9394 Ok(Built::new(shape, history))
9395}
9396
9397fn profile_face(
9405 model: &mut Model,
9406 profile: &Shape,
9407 profile_normal: Vector,
9408 against: Option<Plane>,
9409 tol: Tolerances,
9410) -> OgeomResult<Shape> {
9411 if model.kind_of(profile)? == ShapeType::Face {
9412 return Ok(profile.clone());
9413 }
9414 if model.kind_of(profile)? != ShapeType::Wire {
9415 ogeom_bail!(Construction, "a profile is a wire or a face");
9416 }
9417 let mut edges = ogeom_topo::explore(model, profile, Filter::OfType(ShapeType::Edge))?;
9418 let closed = ogeom_algo::is_wire_closed(model, profile, tol)?;
9419 if !closed {
9420 let Some(plane) = against else {
9421 ogeom_bail!(
9422 Construction,
9423 "an open profile sweeps a shell, not a volume; give the spine \
9424 as a planar face for its plane to close the profile against, \
9425 or close the profile itself"
9426 );
9427 };
9428 let [(from, v0), (to, v1)] = wire_ends(model, profile, tol)?;
9429 for end in [from, to] {
9430 if plane.signed_distance_to(end).abs() > tol.confusion() * 1e2 {
9431 ogeom_bail!(
9432 Construction,
9433 "an open profile is closed against the spine face's own \
9434 plane, and this one does not reach it"
9435 );
9436 }
9437 }
9438 let line = LineCurve::new(ogeom_math::Axis {
9441 location: from,
9442 direction: Direction::new(to - from, tol)?,
9443 });
9444 edges.push(
9445 make_edge_between(model, line.into(), (0.0, from.distance(to)), &v0, &v1, tol)?.shape,
9446 );
9447 }
9448 let ordered = ogeom_algo::order_edges(model, &edges, tol)?;
9449 let mut bound = ogeom_math::Aabb::EMPTY;
9450 for edge in &ordered {
9451 bound = bound.union(&ogeom_algo::shape_bounds(model, edge, tol)?);
9452 }
9453 let Some(centre) = bound.centre() else {
9454 ogeom_bail!(Construction, "a profile with no extent sweeps nothing");
9455 };
9456 let reach = bound.diagonal().mul_add(2.0, 1.0);
9457 let plane = Plane::through(centre, Direction::new(profile_normal, tol)?);
9458 let surface = PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?;
9459 Ok(make_face_with_pcurves(model, surface.into(), &[ordered], tol)?.shape)
9460}
9461
9462fn wire_ends(model: &Model, wire: &Shape, tol: Tolerances) -> OgeomResult<[(Point, Shape); 2]> {
9464 let mut counts: Vec<(Point, Shape, usize)> = Vec::new();
9465 for edge in explore(model, wire, Filter::OfType(ShapeType::Edge))? {
9466 for v in explore(model, &edge, Filter::OfType(ShapeType::Vertex))? {
9467 let Some(data) = model.node(&v).and_then(|n| n.data().as_vertex()) else {
9468 continue;
9469 };
9470 let at = v.transform(model.datums())?.apply(data.point);
9471 match counts
9472 .iter_mut()
9473 .find(|(p, _, _)| p.distance(at) <= tol.confusion() * 10.0)
9474 {
9475 Some((_, _, n)) => *n += 1,
9476 None => counts.push((at, v.clone(), 1)),
9477 }
9478 }
9479 }
9480 let free: Vec<(Point, Shape)> = counts
9481 .into_iter()
9482 .filter(|(_, _, n)| *n == 1)
9483 .map(|(p, v, _)| (p, v))
9484 .collect();
9485 if free.len() != 2 {
9486 ogeom_bail!(
9487 Construction,
9488 "an open profile has exactly two ends; this one has {}",
9489 free.len()
9490 );
9491 }
9492 let mut ends = free.into_iter();
9493 let (Some(a), Some(b)) = (ends.next(), ends.next()) else {
9494 ogeom_bail!(Construction, "the profile lost an end between checks");
9495 };
9496 Ok([a, b])
9497}
9498
9499fn spine_stations(model: &Model, wire: &Shape, tol: Tolerances) -> OgeomResult<Vec<Station>> {
9501 let mut out = Vec::new();
9502 for edge in explore(model, wire, Filter::OfType(ShapeType::Edge))? {
9503 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
9504 ogeom_bail!(Construction, "a spine edge is not in this model");
9505 };
9506 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
9507 ogeom_bail!(Construction, "a spine edge with no curve runs nowhere");
9508 };
9509 let Some(geometry) = model.geometry().curve(*curve) else {
9510 ogeom_bail!(Dangling, "curve is not in this model");
9511 };
9512 let placed = geometry.transformed(&edge.transform(model.datums())?, tol)?;
9513 let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
9514 let (t0, t1) = if reversed {
9515 (range.1, range.0)
9516 } else {
9517 (range.0, range.1)
9518 };
9519 let sign = if reversed { -1.0 } else { 1.0 };
9520 let unit = |t: f64| -> OgeomResult<Vector> {
9521 let d = placed.d1_at(t, tol)? * sign;
9522 if d.magnitude() <= tol.confusion() {
9523 ogeom_bail!(Construction, "a spine edge has no direction at {t}");
9524 }
9525 Ok(d / d.magnitude())
9526 };
9527 let station = match &placed {
9528 Curve::Line(_) => Station {
9529 from: placed.point_at(t0, tol)?,
9530 to: placed.point_at(t1, tol)?,
9531 tangent_in: unit(t0)?,
9532 tangent_out: unit(t1)?,
9533 turn: None,
9534 },
9535 Curve::Circle(c) => {
9536 let circle = c.circle();
9537 let swept = (range.1 - range.0).abs();
9538 let axis = ogeom_math::Axis {
9539 location: circle.centre(),
9540 direction: if reversed {
9541 -circle.frame().z()
9542 } else {
9543 circle.frame().z()
9544 },
9545 };
9546 Station {
9547 from: placed.point_at(t0, tol)?,
9548 to: placed.point_at(t1, tol)?,
9549 tangent_in: unit(t0)?,
9550 tangent_out: unit(t1)?,
9551 turn: Some((axis, swept)),
9552 }
9553 }
9554 other => ogeom_bail!(
9555 Construction,
9556 "a spine runs on straight and circular edges; a {:?} sweeps a \
9557 surface this construction does not have",
9558 other.kind()
9559 ),
9560 };
9561 out.push(station);
9562 }
9563 Ok(out)
9564}
9565
9566fn spine_normal(stations: &[Station], tol: Tolerances) -> OgeomResult<Direction> {
9573 let mut best: Option<(f64, Vector)> = None;
9574 let mut consider = |a: Vector, b: Vector| {
9575 let cross = a.cross(b);
9576 let magnitude = cross.magnitude();
9577 if magnitude > best.map_or(tol.angular(), |(m, _)| m) {
9578 best = Some((magnitude, cross / magnitude));
9579 }
9580 };
9581 for (index, station) in stations.iter().enumerate() {
9582 consider(station.tangent_in, station.tangent_out);
9583 if index + 1 < stations.len() {
9584 consider(station.tangent_out, stations[index + 1].tangent_in);
9585 }
9586 }
9587 if stations.len() > 1 {
9588 consider(
9589 stations[stations.len() - 1].tangent_out,
9590 stations[0].tangent_in,
9591 );
9592 }
9593 let Some((_, normal)) = best else {
9594 ogeom_bail!(
9595 Construction,
9596 "a spine that never turns has no plane of its own; give the \
9597 profile's own orientation a spine with at least one corner or arc"
9598 );
9599 };
9600 for station in stations {
9601 for tangent in [station.tangent_in, station.tangent_out] {
9602 if tangent.dot(normal).abs() > tol.angular() {
9603 ogeom_bail!(
9604 Construction,
9605 "the spine leaves its own plane; an evolved sweep runs a \
9606 planar spine"
9607 );
9608 }
9609 }
9610 if let Some((axis, _)) = &station.turn
9611 && axis.direction.vector().cross(normal).magnitude() > tol.angular()
9612 {
9613 ogeom_bail!(
9614 Construction,
9615 "a spine arc turns about an axis off the spine's own normal"
9616 );
9617 }
9618 }
9619 Direction::new(normal, tol)
9620}
9621
9622fn profile_plane(model: &Model, profile: &Shape, tol: Tolerances) -> OgeomResult<(Point, Vector)> {
9624 let mut points: Vec<Point> = Vec::new();
9625 for edge in explore(model, profile, Filter::OfType(ShapeType::Edge))? {
9626 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
9627 continue;
9628 };
9629 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
9630 continue;
9631 };
9632 let Some(geometry) = model.geometry().curve(*curve) else {
9633 ogeom_bail!(Dangling, "curve is not in this model");
9634 };
9635 let placed = geometry.transformed(&edge.transform(model.datums())?, tol)?;
9636 for i in 0..=8 {
9637 let t = range.0 + (range.1 - range.0) * f64::from(i) / 8.0;
9638 points.push(placed.point_at(t, tol)?);
9639 }
9640 }
9641 if points.len() < 3 {
9642 ogeom_bail!(Construction, "a profile needs an extent to sweep");
9643 }
9644 let origin = points[0];
9645 let mut best: Option<(f64, Vector)> = None;
9649 for (i, a) in points.iter().enumerate() {
9650 for b in points.iter().skip(i + 1) {
9651 let cross = (*a - origin).cross(*b - origin);
9652 let magnitude = cross.magnitude();
9653 if magnitude > best.map_or(tol.confusion(), |(m, _)| m) {
9654 best = Some((magnitude, cross / magnitude));
9655 }
9656 }
9657 }
9658 let Some((_, normal)) = best else {
9659 ogeom_bail!(Construction, "a profile with no area has no plane");
9660 };
9661 for p in &points {
9662 if (*p - origin).dot(normal).abs() > tol.confusion() * 1e2 {
9663 ogeom_bail!(Construction, "the profile is not planar");
9664 }
9665 }
9666 Ok((origin, normal))
9667}
9668
9669fn station_transform(
9672 reference: (Point, Vector),
9673 at: Point,
9674 tangent: Vector,
9675 normal: Direction,
9676 tol: Tolerances,
9677) -> OgeomResult<Transform> {
9678 let (origin, from) = reference;
9679 let n = normal.vector();
9680 let angle = from.cross(tangent).dot(n).atan2(from.dot(tangent));
9681 let turn = if angle.abs() <= tol.angular() {
9682 Transform::IDENTITY
9683 } else {
9684 Transform::rotation(
9685 ogeom_math::Axis {
9686 location: origin,
9687 direction: normal,
9688 },
9689 angle,
9690 )
9691 };
9692 Ok(Transform::translation(at - origin) * turn)
9693}
9694
9695#[allow(clippy::too_many_arguments)]
9701fn corner_piece(
9702 model: &mut Model,
9703 profile: &Shape,
9704 reference: (Point, Vector),
9705 corner: Point,
9706 incoming: Vector,
9707 outgoing: Vector,
9708 normal: Direction,
9709 tol: Tolerances,
9710) -> OgeomResult<Option<Shape>> {
9711 let n = normal.vector();
9712 let angle = incoming
9713 .cross(outgoing)
9714 .dot(n)
9715 .atan2(incoming.dot(outgoing));
9716 if angle.abs() <= tol.angular() {
9717 return Ok(None);
9718 }
9719 let placed = ogeom_algo::transformed(
9720 model,
9721 profile,
9722 station_transform(reference, corner, incoming, normal, tol)?,
9723 )?
9724 .shape;
9725 let axis = ogeom_math::Axis {
9726 location: corner,
9727 direction: if angle > 0.0 { normal } else { -normal },
9728 };
9729 let turned = ogeom_algo::make_revolution(model, &placed, axis, angle.abs(), tol);
9730 match turned {
9731 Ok(built) => Ok(Some(built.shape)),
9732 Err(_) => ogeom_bail!(
9733 NotDone,
9734 "the profile straddles the spine at a corner, so turning it about \
9735 that corner sweeps it through itself; there is no revolution for \
9736 that wedge"
9737 ),
9738 }
9739}
9740
9741fn fuse_in_order(
9747 model: &mut Model,
9748 pieces: Vec<Shape>,
9749 tol: Tolerances,
9750) -> OgeomResult<Option<Shape>> {
9751 let mut runs = pieces;
9752 while runs.len() > 1 {
9753 let mut next = Vec::with_capacity(runs.len().div_ceil(2));
9754 let mut pending = runs.into_iter();
9755 while let Some(first) = pending.next() {
9756 match pending.next() {
9757 Some(second) => next.push(ogeom_bool::fuse(model, &first, &second, tol)?.shape),
9758 None => next.push(first),
9759 }
9760 }
9761 runs = next;
9762 }
9763 Ok(runs.pop())
9764}