ogeom_offset/sweep/placement.rs
1//! Bringing a profile onto a spine before it is swept.
2//!
3//! A pipe shell sweeps a profile standing square to the spine at its
4//! start, through the start point. A profile drawn elsewhere, or leaning,
5//! is brought there by a rigid motion about its centroid (the centre of
6//! the area it bounds): turned by the least rotation that stands its plane
7//! square to the spine's start tangent, and moved so its centroid lands on
8//! the spine's start point.
9//!
10//! A pipe shell may instead bring its profile into contact itself, as
11//! [`PipeContact`] asks: moved along its plane's normal onto the spine's
12//! start, and turned about that point square to the start tangent. That
13//! needs only the profile's plane, so it serves open wires as well as
14//! faces and closed wires, and it keeps the profile's offset from the
15//! spine within its plane.
16
17use ogeom_algo::{Built, find_plane, surface_properties};
18use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
19use ogeom_geom::{Curve3d as _, PlaneSurface};
20use ogeom_math::{Axis, Direction, Point, Transform, Vector};
21use ogeom_mesh::Deflection;
22use ogeom_topo::{Model, Shape, ShapeType};
23
24use super::{moved_ring, realized_profile};
25
26/// How a profile is brought onto a spine by [`place_on_spine`].
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub struct PipePlacement {
29 /// Move the profile, unturned, so its centroid stands on the spine's
30 /// start point.
31 pub contact: bool,
32 /// Turn the profile about its centroid, by the least rotation, until
33 /// its plane stands square to the spine's tangent at its start.
34 pub correction: bool,
35}
36
37/// How a pipe shell brings its profile onto the spine before sweeping it,
38/// a rigid motion of the profile defined by the profile's plane and the
39/// spine's start alone.
40///
41/// The contact point is the foot of the spine's start point on the
42/// profile's plane. It is one point for any planar profile, an open wire
43/// as much as a face, and a face and its outer wire share it; the
44/// profile's own point nearest the start would not be (every point of a
45/// ring about the start is nearest at once, and a face's nearest point
46/// can lie inside it where its boundary's cannot).
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum PipeContact {
49 /// The profile is swept where it stands: square to the spine at its
50 /// start and through the start point, or refused.
51 #[default]
52 AsPlaced,
53 /// The profile is moved, unturned, along its plane's normal by the
54 /// distance from its plane to the spine's start point, so that the
55 /// contact point lands on the start point. A profile already within
56 /// the confusion distance of the start is swept as it stands.
57 Contact,
58 /// As `Contact`, then the profile is turned about the spine's start
59 /// point by the least rotation standing its plane square to the
60 /// spine's start tangent: about the axis square to both, through the
61 /// angle between the tangent and the plane's normal taken whichever
62 /// way round the normal is nearer the tangent. A plane has no side, so
63 /// a normal pointing back along the tangent is already square and the
64 /// profile is not turned. A plane holding the tangent (to an angle's
65 /// tolerance) is a quarter turn from square either way round, and is
66 /// refused.
67 Correction,
68}
69
70/// A planar profile (a face, or a closed wire) moved rigidly onto the start
71/// of `spine` as `placement` asks: turned about its centroid until it
72/// stands square to the spine's start tangent where `correction`, and
73/// moved so its centroid is the spine's start point where `contact`. The
74/// result is a fresh profile, ready for [`make_pipe_shell`](super::make_pipe_shell)
75/// and the sweeps like it; the history records it as the profile
76/// modified.
77///
78/// # Errors
79///
80/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
81/// the profile is not a planar face or closed wire, or the spine is not an
82/// edge or a wire with a tangent at its start.
83pub fn place_on_spine(
84 model: &mut Model,
85 profile: &Shape,
86 spine: &Shape,
87 placement: PipePlacement,
88 tol: Tolerances,
89) -> OgeomResult<Built> {
90 let kind = model.kind_of(profile)?;
91 if !matches!(kind, ShapeType::Face | ShapeType::Wire) {
92 ogeom_bail!(
93 Construction,
94 "a profile is a planar face or closed wire, not a {kind:?}"
95 );
96 }
97 let Some(plane) = find_plane(model, profile, tol)? else {
98 ogeom_bail!(Construction, "a pipe shell sweeps a planar profile");
99 };
100 let (start, tangent) = spine_start(model, spine, tol)?;
101 let centre = centroid(model, profile, kind, &plane, tol)?;
102 let mut motion = Transform::IDENTITY;
103 if placement.correction
104 && let Some(turn) = least_turn(plane.normal().vector(), tangent, centre, tol)?
105 {
106 motion = turn;
107 }
108 if placement.contact {
109 motion = Transform::translation(start - centre) * motion;
110 }
111 let placed = match kind {
112 ShapeType::Face => realized_profile(model, profile, &motion, tol)?,
113 _ => moved_ring(model, profile, &motion, tol)?,
114 };
115 let mut built = Built::new(placed.clone(), ogeom_algo::History::new());
116 built.history.modify(profile, placed);
117 Ok(built)
118}
119
120/// The least rotation about `about` standing a plane of normal `normal`
121/// square to the unit `tangent`, or `None` where it stands square already
122/// to an angle's tolerance. The plane faces whichever way round is nearer
123/// the tangent.
124fn least_turn(
125 normal: Vector,
126 tangent: Vector,
127 about: Point,
128 tol: Tolerances,
129) -> OgeomResult<Option<Transform>> {
130 let n = if normal.dot(tangent) < 0.0 {
131 -normal
132 } else {
133 normal
134 };
135 let axis = n.cross(tangent);
136 if axis.magnitude() <= tol.angular() {
137 return Ok(None);
138 }
139 let about = Axis {
140 location: about,
141 direction: Direction::new(axis, tol)?,
142 };
143 Ok(Some(Transform::rotation(
144 about,
145 axis.magnitude().atan2(n.dot(tangent)),
146 )))
147}
148
149/// The profile a pipe shell sweeps under `contact`: `profile` itself where
150/// it is swept as placed or the motion moves nothing, else a fresh profile
151/// moved rigidly as [`PipeContact`] defines.
152///
153/// # Errors
154///
155/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
156/// the profile is not a planar wire or face, if the spine has no tangent
157/// at its start, or if a correction is asked of a profile whose plane
158/// holds the start tangent.
159pub(crate) fn profile_in_contact(
160 model: &mut Model,
161 profile: &Shape,
162 spine: &Shape,
163 contact: PipeContact,
164 tol: Tolerances,
165) -> OgeomResult<Shape> {
166 if contact == PipeContact::AsPlaced {
167 return Ok(profile.clone());
168 }
169 let kind = model.kind_of(profile)?;
170 if !matches!(kind, ShapeType::Face | ShapeType::Wire) {
171 ogeom_bail!(
172 Construction,
173 "a pipe shell sweeps a planar wire or face, not a {kind:?}"
174 );
175 }
176 let Some(plane) = find_plane(model, profile, tol)? else {
177 ogeom_bail!(
178 Construction,
179 "a profile is brought into contact along its plane's normal; this \
180 one is not planar"
181 );
182 };
183 let (start, tangent) = spine_start(model, spine, tol)?;
184 let normal = plane.normal().vector();
185 let shift = normal * (start - plane.origin()).dot(normal);
186 let mut motion = Transform::translation(shift);
187 let mut moves = shift.magnitude() > tol.confusion();
188 if contact == PipeContact::Correction {
189 if normal.dot(tangent).abs() <= tol.angular() {
190 ogeom_bail!(
191 Construction,
192 "the profile's plane holds the spine's start tangent: a quarter \
193 turn either way round stands it square, so its correction is \
194 not one turn"
195 );
196 }
197 if let Some(turn) = least_turn(normal, tangent, start, tol)? {
198 motion = turn * motion;
199 moves = true;
200 }
201 }
202 if !moves {
203 return Ok(profile.clone());
204 }
205 match kind {
206 ShapeType::Face => realized_profile(model, profile, &motion, tol),
207 _ => moved_ring(model, profile, &motion, tol),
208 }
209}
210
211/// The spine's start point and its unit tangent there, in the direction
212/// of travel, read exactly off its first edge.
213fn spine_start(model: &Model, spine: &Shape, tol: Tolerances) -> OgeomResult<(Point, Vector)> {
214 let first = match model.kind_of(spine)? {
215 ShapeType::Edge => spine.clone(),
216 ShapeType::Wire => match model.ordered_children_of(spine)?.first() {
217 Some(edge) => edge.clone(),
218 None => ogeom_bail!(Construction, "the spine has no edge"),
219 },
220 other => ogeom_bail!(
221 Construction,
222 "a spine is an edge or a wire, not a {other:?}"
223 ),
224 };
225 let Some((curve, range)) = ogeom_algo::placed_edge_curve(model, &first, tol)? else {
226 ogeom_bail!(Construction, "an edge has no curve");
227 };
228 let reversed = first.orientation() == ogeom_topo::Orientation::Reversed;
229 let t = if reversed { range.1 } else { range.0 };
230 let d = curve.d1_at(t, tol)?;
231 let d = if reversed { -d } else { d };
232 if d.magnitude() <= tol.confusion() {
233 ogeom_bail!(Construction, "the spine has no tangent at its start");
234 }
235 Ok((curve.point_at(t, tol)?, d / d.magnitude()))
236}
237
238/// The centroid of the area a planar profile bounds: a face's own, or a
239/// closed wire's as the face it bounds on `plane`.
240fn centroid(
241 model: &mut Model,
242 profile: &Shape,
243 kind: ShapeType,
244 plane: &ogeom_math::Plane,
245 tol: Tolerances,
246) -> OgeomResult<Point> {
247 let face = if kind == ShapeType::Face {
248 profile.clone()
249 } else {
250 ogeom_algo::make_face(
251 model,
252 PlaneSurface::new(*plane).into(),
253 std::slice::from_ref(profile),
254 tol,
255 )?
256 .shape
257 };
258 // Rebuilt with its pcurves on the plane, the face is integrated exactly
259 // where its edges allow; else over a mesh as fine as the model's
260 // confusion.
261 let charted = realized_profile(model, &face, &Transform::IDENTITY, tol)?;
262 let chord = Deflection::with_chord(tol.confusion())?;
263 Ok(surface_properties(model, &charted, chord, tol)?.centre)
264}