Skip to main content

geop_ops_plastic/
band.rs

1//! Chains of curves in a plane, offset sideways — and the band between two
2//! offsets of one chain, the profile a rib or a lip is swept from.
3//!
4//! **Offsetting.** A line moves sideways, an arc grows or shrinks around
5//! its centre: both stay exactly what they were. The offset of any other
6//! curve is no NURBS: a cubic is fitted through points of it, as many as
7//! it takes to come within [`FIT_TOLERANCE`] of the distance everywhere,
8//! proven ([`NurbCurve2D::offset_miss`]). Where two pieces meet smoothly
9//! their offsets meet at the joint moved along its normal; where two lines
10//! meet at a corner their offsets are cut back, or run on, to where they
11//! cross — a mitre. A corner involving any other piece is not supported:
12//! its offsets would need a round of their own, or a cut at an angle no
13//! piece defines.
14
15use geop_core_geometry::nurb_curve::{NurbCurve2D, loop_signed_area};
16use geop_core_math::{
17    geop_error::{GeopError, GeopResult},
18    scalars::Scalar,
19    vector::Vector2,
20};
21use geop_ops_model::Profile;
22
23/// How near a fitted offset comes to the true one, relative to the
24/// distance: the accuracy a band's side is made to, not a comparison.
25const FIT_TOLERANCE: f64 = 1e-6;
26
27/// The most points a fitted offset is fitted through.
28const MOST_FIT_POINTS: usize = 1024;
29
30/// A curve of a chain, from where the previous one ends, named for what it
31/// was made from.
32#[derive(Clone, Debug)]
33pub struct Piece {
34    pub curve: NurbCurve2D,
35    /// The centre of the circle an arc lies on; none for a line or any
36    /// other curve.
37    pub center: Option<Vector2>,
38    pub name: String,
39}
40
41/// A chain of pieces, each starting where the one before ends — closed if
42/// the last ends where the first starts — and the names of its joints:
43/// `joints[i]` where piece `i` starts, and for an open chain one more, its
44/// end.
45#[derive(Clone, Debug)]
46pub struct Chain {
47    pub pieces: Vec<Piece>,
48    pub joints: Vec<String>,
49    /// Per piece, whether the chain is known to run on smoothly into it
50    /// from the piece before — as the faces of a solid say, where a
51    /// traced piece's tangent only encloses its direction. Where not, its
52    /// pieces' tangents decide.
53    pub smooth: Vec<bool>,
54    pub closed: bool,
55}
56
57impl Piece {
58    fn start(&self) -> GeopResult<Vector2> {
59        self.curve.start()
60    }
61
62    fn end(&self) -> GeopResult<Vector2> {
63        self.curve.end()
64    }
65
66    /// The unit direction it starts and ends going in.
67    fn directions(&self) -> GeopResult<(Vector2, Vector2)> {
68        let (t0, t1) = self.curve.domain();
69        Ok((
70            self.curve.tangent(t0)?.normalize()?,
71            self.curve.tangent(t1)?.normalize()?,
72        ))
73    }
74
75    /// The same piece `distance` to its left — negative: to its right —
76    /// its ends not yet fitted to its neighbours'.
77    fn offset(&self, distance: Scalar) -> GeopResult<NurbCurve2D> {
78        let mut curve = self.curve.clone();
79        let (start, _) = self.directions()?;
80        match &self.center {
81            None if !self.is_straight() => return self.fitted_offset(distance),
82            None => {
83                let shift = start.perp().prod_scalar(distance);
84                for cp in &mut curve.control_points {
85                    cp[0] = cp[0].add(shift[0].mul(cp[2]));
86                    cp[1] = cp[1].add(shift[1].mul(cp[2]));
87                }
88            }
89            Some(center) => {
90                let from = self.start()?;
91                let radius = from.sub(center).norm();
92                // The centre lies on the left of an arc turning left: an
93                // offset to the left comes closer to it.
94                let turning_left = start
95                    .prod_cross(&center.sub(&from))
96                    .definitely_greater(Scalar::ZERO);
97                let new_radius = if turning_left {
98                    radius.sub(distance)
99                } else {
100                    radius.add(distance)
101                };
102                if !new_radius.definitely_greater(Scalar::ZERO) {
103                    return Err(GeopError::new(format!(
104                        "{} curves too tightly: its radius {:?} leaves no room for an offset of {:?}",
105                        self.name,
106                        radius.to_f64(),
107                        distance.to_f64()
108                    )));
109                }
110                let scale = new_radius.div(radius)?;
111                for cp in &mut curve.control_points {
112                    for k in 0..2 {
113                        let c = center[k].mul(cp[2]);
114                        cp[k] = c.add(cp[k].sub(c).mul(scale));
115                    }
116                }
117            }
118        }
119        curve.recompute_aabb();
120        Ok(curve)
121    }
122}
123
124impl Piece {
125    /// Whether it is a line.
126    pub(crate) fn is_straight(&self) -> bool {
127        self.curve.degree == 1 && self.curve.control_points.len() == 2
128    }
129
130    /// A cubic through points of its offset `distance` to its left, at
131    /// parameters spaced evenly over each of its spans, the fit's
132    /// parameters following its own: twice as many while the fit is
133    /// further than [`FIT_TOLERANCE`] of the distance from the offset.
134    fn fitted_offset(&self, distance: Scalar) -> GeopResult<NurbCurve2D> {
135        let ctx = |e: GeopError| {
136            e.with_context(format!(
137                "the offset of {} by {:?}",
138                self.name,
139                distance.to_f64()
140            ))
141        };
142        let curve = &self.curve;
143        let (t0, t1) = curve.domain();
144        let tolerance = distance.abs().mul(Scalar::from_f64(FIT_TOLERANCE));
145        let breaks = curve.breakpoints();
146        let spans = breaks.len() - 1;
147        let mut per_span = 4;
148        let mut last: Option<(Scalar, NurbCurve2D)> = None;
149        loop {
150            // The parameters are free choices: sharp ones.
151            let mut ts = vec![t0];
152            for w in breaks.windows(2) {
153                for k in 1..=per_span {
154                    let f = Scalar::from_ratio(k as i64, per_span as i64)?;
155                    ts.push(w[0].add(w[1].sub(w[0]).mul(f)).sharpen());
156                }
157            }
158            *ts.last_mut().expect("a parameter") = t1;
159            let span = t1.sub(t0);
160            let mut params = Vec::with_capacity(ts.len());
161            let mut values = Vec::with_capacity(ts.len());
162            for (k, &t) in ts.iter().enumerate() {
163                params.push(match k {
164                    0 => Scalar::ZERO,
165                    k if k + 1 == ts.len() => Scalar::ONE,
166                    _ => t.sub(t0).div(span).map_err(ctx)?.sharpen(),
167                });
168                let normal = curve
169                    .tangent(t)
170                    .map_err(ctx)?
171                    .perp()
172                    .normalize()
173                    .map_err(ctx)?;
174                // A point of the offset, any of its enclosure: the fit is a
175                // free choice near the offset, proven so below.
176                let p = curve
177                    .evaluate(t)?
178                    .add(&normal.prod_scalar(distance))
179                    .sharpen();
180                values.push(p.homogeneous(Scalar::ONE));
181            }
182            let fit = NurbCurve2D::interpolate_homogeneous(&values, &params, 3).map_err(ctx)?;
183            let miss = curve
184                .offset_miss(distance, (t0, t1), &fit, tolerance)
185                .map_err(ctx)?;
186            if !miss.definitely_greater(tolerance) {
187                return Ok(fit);
188            }
189            // Where twice the points no longer halve the miss, it is the
190            // curve's own width, and the coarser fit is as near.
191            if let Some((before, coarser)) = last
192                && !before.definitely_greater(miss.mul(Scalar::TWO))
193            {
194                return Ok(coarser);
195            }
196            if 2 * per_span * spans > MOST_FIT_POINTS {
197                return Err(ctx(GeopError::new(format!(
198                    "a cubic through {} points of it is still {miss:?} off",
199                    per_span * spans + 1
200                ))));
201            }
202            per_span *= 2;
203            last = Some((miss, fit));
204        }
205    }
206}
207
208/// `curve` with its ends moved to exactly `start` and `end` — the same
209/// points up to rounding, made exact so the pieces of a loop meet.
210fn with_ends(curve: &NurbCurve2D, start: Vector2, end: Vector2) -> GeopResult<NurbCurve2D> {
211    if curve.degree == 1 && curve.control_points.len() == 2 {
212        return NurbCurve2D::line(start, end);
213    }
214    let mut curve = curve.clone();
215    let last = curve.control_points.len() - 1;
216    for (i, p) in [(0, start), (last, end)] {
217        curve.control_points[i] = p.homogeneous(curve.control_points[i][2]);
218    }
219    curve.recompute_aabb();
220    Ok(curve)
221}
222
223impl Chain {
224    /// The chain moved `distance` to its left — negative: to its right —
225    /// see the module docs.
226    pub fn offset(&self, distance: Scalar) -> GeopResult<Vec<NurbCurve2D>> {
227        let n = self.pieces.len();
228        let raw = self
229            .pieces
230            .iter()
231            .map(|p| p.offset(distance))
232            .collect::<GeopResult<Vec<_>>>()?;
233        // Where each piece's offset starts: at a joint between two pieces,
234        // where their offsets meet.
235        let mut starts = Vec::with_capacity(n + 1);
236        for i in 0..n {
237            starts.push(if i == 0 && !self.closed {
238                raw[0].start()?
239            } else {
240                self.joint(i, distance, &raw)?
241            });
242        }
243        starts.push(if self.closed {
244            starts[0]
245        } else {
246            raw[n - 1].end()?
247        });
248        (0..n)
249            .map(|i| {
250                let curve = with_ends(&raw[i], starts[i], starts[i + 1])?;
251                // A line cut back past its other end turns around.
252                if self.pieces[i].is_straight() {
253                    let (old, new) = (
254                        self.pieces[i].end()?.sub(&self.pieces[i].start()?),
255                        starts[i + 1].sub(&starts[i]),
256                    );
257                    if !old.prod_dot(&new).definitely_greater(Scalar::ZERO) {
258                        return Err(GeopError::new(format!(
259                            "{} is too short for an offset of {:?}: it would turn around",
260                            self.pieces[i].name,
261                            distance.to_f64()
262                        )));
263                    }
264                }
265                Ok(curve)
266            })
267            .collect()
268    }
269
270    /// Where the offsets `raw` of the pieces before and after joint `i` meet.
271    fn joint(&self, i: usize, distance: Scalar, raw: &[NurbCurve2D]) -> GeopResult<Vector2> {
272        let n = self.pieces.len();
273        let before = (i + n - 1) % n;
274        let (_, arriving) = self.pieces[before].directions()?;
275        let (leaving, _) = self.pieces[i].directions()?;
276        let at = self.pieces[i].start()?;
277        if self.smooth[i]
278            || (arriving.prod_cross(&leaving).could_be_equal(Scalar::ZERO)
279                && arriving.prod_dot(&leaving).definitely_greater(Scalar::ZERO))
280        {
281            // Along the normal of a line there, if one meets it: exact.
282            let along = if self.pieces[before].is_straight() && !self.pieces[i].is_straight() {
283                arriving
284            } else {
285                leaving
286            };
287            return Ok(at.add(&along.perp().prod_scalar(distance)));
288        }
289        if !(self.pieces[before].is_straight() && self.pieces[i].is_straight()) {
290            return Err(GeopError::new(format!(
291                "{} and {} meet at a corner, at {}: an offset round a corner is only supported between straight pieces",
292                self.pieces[before].name, self.pieces[i].name, self.joints[i]
293            )));
294        }
295        // Where the two offset lines cross.
296        let a = raw[before].end()?;
297        let b = raw[i].start()?;
298        let denominator = arriving.prod_cross(&leaving);
299        let s = b.sub(&a).prod_cross(&leaving).div(denominator)?;
300        Ok(a.add(&arriving.prod_scalar(s)))
301    }
302
303    /// The band between the chain moved `near` and `far` to its left
304    /// (`near < far`; zero for the chain itself) as the loops of a region,
305    /// the outer one first and counter-clockwise, named after the pieces
306    /// and joints: `X` and `P` for the near side, `X,far` and `P,far` for
307    /// the far one, `first` and `last` the ends of an open chain.
308    pub fn band(&self, near: Scalar, far: Scalar) -> GeopResult<Vec<Profile>> {
309        let at = |distance: Scalar| -> GeopResult<Vec<NurbCurve2D>> {
310            if distance.could_be_equal(Scalar::ZERO) && distance.is_sharp() {
311                // The chain itself, not moved by a rounded zero.
312                Ok(self.pieces.iter().map(|p| p.curve.clone()).collect())
313            } else {
314                self.offset(distance)
315            }
316        };
317        let (near_curves, far_curves) = (at(near)?, at(far)?);
318        let names: Vec<String> = self.pieces.iter().map(|p| p.name.clone()).collect();
319        let far_name = |name: &str| format!("{name},far");
320        let near_side = Profile {
321            name: String::new(),
322            curves: near_curves,
323            curve_names: names.clone(),
324            joint_names: self.joints.clone(),
325        };
326        let far_side = Profile {
327            name: String::new(),
328            curves: far_curves,
329            curve_names: names.iter().map(|n| far_name(n)).collect(),
330            joint_names: self.joints.iter().map(|n| far_name(n)).collect(),
331        };
332        if !self.closed {
333            // Along the near side, back along the far one: the band is on
334            // the left all the way round.
335            let near_end = near_side.curves.last().unwrap().end()?;
336            let near_start = near_side.curves[0].start()?;
337            let far = far_side.reversed();
338            let far_start = far.curves[0].start()?;
339            let far_end = far.curves.last().unwrap().end()?;
340            let mut curves = near_side.curves;
341            curves.push(NurbCurve2D::line(near_end, far_start)?);
342            curves.extend(far.curves);
343            curves.push(NurbCurve2D::line(far_end, near_start)?);
344            let mut curve_names = near_side.curve_names;
345            curve_names.push("last".into());
346            curve_names.extend(far.curve_names);
347            curve_names.push("first".into());
348            let mut joint_names = near_side.joint_names;
349            joint_names.extend(far.joint_names);
350            return Ok(vec![Profile {
351                name: String::new(),
352                curves,
353                curve_names,
354                joint_names,
355            }]);
356        }
357        // Closed: the band is the ring between the two. The one further
358        // left encloses the other if the chain winds clockwise.
359        if self.winds_counter_clockwise()? {
360            Ok(vec![near_side, far_side.reversed()])
361        } else {
362            Ok(vec![far_side.reversed(), near_side])
363        }
364    }
365
366    /// Whether the closed chain winds counter-clockwise, from the signed
367    /// area it encloses, integrated along its curves.
368    fn winds_counter_clockwise(&self) -> GeopResult<bool> {
369        let area = loop_signed_area(self.pieces.iter().map(|p| &p.curve))?;
370        if area.definitely_greater(Scalar::ZERO) {
371            Ok(true)
372        } else if area.definitely_less(Scalar::ZERO) {
373            Ok(false)
374        } else {
375            Err(GeopError::new("cannot tell which way the chain winds"))
376        }
377    }
378}
379
380#[cfg(test)]
381mod tests;