Skip to main content

ogeom_offset/
middle.rs

1//! The middle path of a pipe-like solid: the curve its cross-sections'
2//! centroids trace from one end face to the other.
3//!
4//! The solid is meshed once, and the march cuts that mesh with planes.
5//! Each station is found by predictor and corrector: a plane square to the
6//! current tangent one step ahead gives a first centroid, the chord to it
7//! turns the tangent (on a circular spine the chord's direction is the mean
8//! of its end tangents), and the plane square to the turned tangent through
9//! the centroid gives the next, until the station stops moving. A plane
10//! square to a tube's own spine cuts it in a section centred on the spine,
11//! so the stations sit on the spine to within the mesh's chord.
12//!
13//! The spine is fitted through the stations and then measured: planes
14//! square to the fitted curve between the stations are cut again, and the
15//! largest distance from a cut's centroid to the curve is the deviation
16//! reported. A deviation over the tolerance halves the step and marches
17//! again.
18
19use ogeom_algo::{Built, History, make_edge, make_wire};
20use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
21use ogeom_geom::{Curve, Curve3d as _, LineCurve};
22use ogeom_math::{Point, Vector};
23use ogeom_mesh::{Deflection, triangulate, triangulate_face};
24use ogeom_topo::{Filter, Model, Shape, ShapeType, Triangulation, explore};
25use std::collections::HashMap;
26
27/// A middle path and how closely it follows the solid's sections.
28#[derive(Debug, Clone)]
29pub struct MiddlePath {
30    /// The wire of the path, from the start face's centroid to the end
31    /// face's, and its history: both end faces generate it.
32    pub built: Built,
33    /// The largest distance, measured, from the centroid of a section cut
34    /// square to the path to the path itself.
35    pub deviation: f64,
36}
37
38/// The steps the march may halve before it gives up on the tolerance.
39const MAX_REFINEMENTS: usize = 5;
40
41/// The stations one march may place before it is taken to be lost.
42const MAX_STATIONS: usize = 4000;
43
44/// The centre line of a pipe-like `solid`, from its face `start` to its
45/// face `end`.
46///
47/// Each point of the path is the centroid of the solid's section by a
48/// plane square to the path there. The path starts at the centroid of
49/// `start` and ends at the centroid of `end`, and it is a straight edge
50/// where every station lies on one line and a fitted spline otherwise.
51/// `tolerance` bounds the reported deviation; the solid is meshed at a
52/// quarter of it.
53///
54/// # Errors
55///
56/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
57/// `solid` is not a solid, either face is not one of its faces, the two
58/// faces are the same, the tolerance is not a positive distance, or the
59/// mesh does not close. [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone)
60/// if a section square to the march finds no material (the solid is not
61/// pipe-like between the faces), the march never reaches `end`, or the
62/// deviation stays over the tolerance after every refinement.
63pub fn middle_path(
64    model: &mut Model,
65    solid: &Shape,
66    start: &Shape,
67    end: &Shape,
68    tolerance: f64,
69    tol: Tolerances,
70) -> OgeomResult<MiddlePath> {
71    if !tolerance.is_finite() || tolerance <= tol.confusion() {
72        ogeom_bail!(
73            Construction,
74            "a middle path to {tolerance} is not a distance"
75        );
76    }
77    if model.kind_of(solid)? != ShapeType::Solid {
78        ogeom_bail!(Construction, "a middle path runs through a solid");
79    }
80    let faces = explore(model, solid, Filter::OfType(ShapeType::Face))?;
81    for face in [start, end] {
82        if !faces.iter().any(|f| f.is_same(face)) {
83            ogeom_bail!(Construction, "the end faces must be faces of the solid");
84        }
85    }
86    if start.is_same(end) {
87        ogeom_bail!(Construction, "a middle path needs two different end faces");
88    }
89
90    let deflection = Deflection {
91        chord: tolerance * 0.25,
92        ..Deflection::default()
93    };
94    let mesh = Slicer::new(triangulate(model, solid, deflection, tol)?);
95    if !mesh.closed {
96        ogeom_bail!(
97            Construction,
98            "the solid's mesh does not close, so its sections are not regions"
99        );
100    }
101    let (c0, n0, area0) = face_centroid(model, start, deflection, tol)?;
102    let (ce, _, _) = face_centroid(model, end, deflection, tol)?;
103    let size = (area0 / core::f64::consts::PI).sqrt();
104    if size <= tol.confusion() {
105        ogeom_bail!(Construction, "the start face has no area to start from");
106    }
107
108    // Into the solid: the side where a nearby plane cuts material round
109    // the start face's centroid.
110    let probe = size * 0.05;
111    let t0 = if mesh.section(c0 + n0 * probe, n0, tol).is_some() {
112        n0
113    } else if mesh.section(c0 - n0 * probe, n0, tol).is_some() {
114        -n0
115    } else {
116        ogeom_bail!(NotDone, "no material lies behind the start face");
117    };
118
119    let mut step = size;
120    let mut last = None;
121    for _ in 0..=MAX_REFINEMENTS {
122        let stations = march(&mesh, c0, t0, ce, step, tol)?;
123        let curve = fit_stations(&stations, tolerance, tol)?;
124        let deviation = measure(&mesh, &curve, stations.len(), tol)?;
125        if deviation <= tolerance {
126            return build(model, curve, start, end, deviation, tol);
127        }
128        last = Some(deviation);
129        step *= 0.5;
130    }
131    ogeom_bail!(
132        NotDone,
133        "the middle path reached a deviation of {} against a tolerance of {tolerance}",
134        last.unwrap_or(f64::INFINITY)
135    )
136}
137
138/// March from `c0` along `t0` until the end centroid `ce` is within a step
139/// and ahead. Near is not enough: a ring bent almost shut starts beside its
140/// own end face.
141fn march(
142    mesh: &Slicer,
143    c0: Point,
144    t0: Vector,
145    ce: Point,
146    step: f64,
147    tol: Tolerances,
148) -> OgeomResult<Vec<Point>> {
149    let mut stations = vec![c0];
150    let (mut c, mut t) = (c0, t0);
151    while c.distance(ce) > step * 1.25 || (ce - c).dot(t) < 0.5 * c.distance(ce) {
152        if stations.len() > MAX_STATIONS {
153            ogeom_bail!(NotDone, "the middle path never reached the end face");
154        }
155        let Some(mut q) = mesh.section(c + t * step, t, tol) else {
156            ogeom_bail!(
157                NotDone,
158                "a section square to the path finds no material {} along from {:?}",
159                step,
160                c
161            );
162        };
163        let mut turned = t;
164        for _ in 0..8 {
165            let chord = (q - c).normalized(tol)?;
166            turned = (chord * (2.0 * t.dot(chord)) - t).normalized(tol)?;
167            let Some(next) = mesh.section(q, turned, tol) else {
168                break;
169            };
170            let moved = next.distance(q);
171            q = next;
172            if moved <= tol.confusion() {
173                break;
174            }
175        }
176        // A station that does not advance toward the end means the march
177        // turned back on itself.
178        if (q - c).dot(t) <= 0.0 {
179            ogeom_bail!(NotDone, "the middle path turned back on itself");
180        }
181        stations.push(q);
182        (c, t) = (q, turned);
183    }
184    stations.push(ce);
185    Ok(stations)
186}
187
188/// A straight line where the stations are collinear, else a fitted spline.
189fn fit_stations(stations: &[Point], tolerance: f64, tol: Tolerances) -> OgeomResult<Curve> {
190    let (first, last) = (stations[0], stations[stations.len() - 1]);
191    let axis = (last - first).normalized(tol)?;
192    let off_line = stations
193        .iter()
194        .map(|p| {
195            let d = *p - first;
196            (d - axis * d.dot(axis)).magnitude()
197        })
198        .fold(0.0_f64, f64::max);
199    if off_line <= tolerance * 0.05 {
200        return Ok(Curve::Line(LineCurve::segment(first, last, tol)?));
201    }
202    let fitted = ogeom_geom::fit::fit_points(stations, 3, tolerance * 0.1, tol)?;
203    Ok(Curve::BSpline(fitted.curve))
204}
205
206/// The largest distance from a section's centroid to the curve, cut square
207/// to the curve at points between the stations.
208fn measure(mesh: &Slicer, curve: &Curve, stations: usize, tol: Tolerances) -> OgeomResult<f64> {
209    let (lo, hi) = curve.domain();
210    let samples = (stations * 2).max(8);
211    let mut worst = 0.0_f64;
212    // The ends stand on the end faces, whose centroids are the path's ends
213    // by construction; only the inside is measured.
214    for i in 1..samples {
215        #[allow(clippy::cast_precision_loss)]
216        let u = lo + (hi - lo) * (i as f64) / (samples as f64);
217        let p = curve.point_at(u, tol)?;
218        let d = curve.d1_at(u, tol)?.normalized(tol)?;
219        let Some(q) = mesh.section(p, d, tol) else {
220            ogeom_bail!(
221                NotDone,
222                "a section square to the fitted path finds no material"
223            );
224        };
225        worst = worst.max(q.distance(p));
226    }
227    Ok(worst)
228}
229
230fn build(
231    model: &mut Model,
232    curve: Curve,
233    start: &Shape,
234    end: &Shape,
235    deviation: f64,
236    tol: Tolerances,
237) -> OgeomResult<MiddlePath> {
238    let domain = curve.domain();
239    let edge = make_edge(model, curve, domain, tol)?.shape;
240    let wire = make_wire(model, &[edge], tol)?.shape;
241    let mut history = History::new();
242    history.generate(start, wire.clone());
243    history.generate(end, wire.clone());
244    Ok(MiddlePath {
245        built: Built::new(wire, history),
246        deviation,
247    })
248}
249
250/// A face's area centroid, its mean outward normal and its area, off its
251/// mesh.
252fn face_centroid(
253    model: &Model,
254    face: &Shape,
255    deflection: Deflection,
256    tol: Tolerances,
257) -> OgeomResult<(Point, Vector, f64)> {
258    let mesh = triangulate_face(model, face, deflection, tol)?;
259    let (mut weighted, mut normal, mut area) = (Vector::ZERO, Vector::ZERO, 0.0);
260    for t in &mesh.triangles {
261        let [a, b, c] = t.map(|i| mesh.positions[i as usize]);
262        let n = (b - a).cross(c - a) * 0.5;
263        let da = n.magnitude();
264        weighted += (a.to_vector() + b.to_vector() + c.to_vector()) * (da / 3.0);
265        normal += n;
266        area += da;
267    }
268    if area <= 0.0 {
269        ogeom_bail!(Construction, "an end face has no area");
270    }
271    Ok((
272        Point::from_vector(weighted * (1.0 / area)),
273        normal.normalized(tol)?,
274        area,
275    ))
276}
277
278/// A closed triangle mesh, cut by planes.
279struct Slicer {
280    mesh: Triangulation,
281    closed: bool,
282}
283
284impl Slicer {
285    fn new(mesh: Triangulation) -> Self {
286        let closed = !mesh.triangles.is_empty() && mesh.is_closed();
287        Self { mesh, closed }
288    }
289
290    /// The centroid of the region the plane through `origin` square to
291    /// `normal` cuts from the solid, taking the outer loop round `origin`
292    /// with the loops inside that as holes. `None` where no loop holds
293    /// `origin`: the point is not inside the material.
294    fn section(&self, origin: Point, normal: Vector, tol: Tolerances) -> Option<Point> {
295        let n = normal.normalized(tol).ok()?;
296        let helper = if n.x.abs() < 0.9 {
297            Vector::X
298        } else {
299            Vector::Y
300        };
301        let e1 = n.cross(helper).normalized(tol).ok()?;
302        let e2 = n.cross(e1);
303        let flat = |p: Point| {
304            let d = p - origin;
305            (d.dot(e1), d.dot(e2))
306        };
307
308        let positions = &self.mesh.positions;
309        let side: Vec<f64> = positions.iter().map(|p| (*p - origin).dot(n)).collect();
310        // A vertex exactly on the plane counts as above it, so every
311        // crossed triangle has exactly two crossed sides.
312        let above = |i: u32| side[i as usize] >= 0.0;
313        let crossing = |a: u32, b: u32| {
314            let (da, db) = (side[a as usize], side[b as usize]);
315            let s = da / (da - db);
316            positions[a as usize].lerp(positions[b as usize], s)
317        };
318        let key = |a: u32, b: u32| if a < b { (a, b) } else { (b, a) };
319
320        // Each crossed triangle gives a segment from one crossed side to
321        // the other, oriented so that loops run consistently.
322        let mut next: HashMap<(u32, u32), (u32, u32)> = HashMap::new();
323        let mut points: HashMap<(u32, u32), Point> = HashMap::new();
324        for t in &self.mesh.triangles {
325            let ups = t.iter().filter(|&&i| above(i)).count();
326            if ups == 0 || ups == 3 {
327                continue;
328            }
329            let mut sides = [(0u32, 0u32); 2];
330            let mut found = 0;
331            for k in 0..3 {
332                let (a, b) = (t[k], t[(k + 1) % 3]);
333                if above(a) != above(b) && found < 2 {
334                    // The side leaving the upper half first, so the segment
335                    // runs the same way round every loop.
336                    sides[found] = (a, b);
337                    found += 1;
338                }
339            }
340            let (s0, s1) = if above(sides[0].0) {
341                (sides[0], sides[1])
342            } else {
343                (sides[1], sides[0])
344            };
345            points.insert(key(s0.0, s0.1), crossing(s0.0, s0.1));
346            points.insert(key(s1.0, s1.1), crossing(s1.0, s1.1));
347            next.insert(key(s0.0, s0.1), key(s1.0, s1.1));
348        }
349        if next.is_empty() {
350            return None;
351        }
352
353        // Chain into loops.
354        let mut loops: Vec<Vec<(f64, f64)>> = Vec::new();
355        let mut seen: HashMap<(u32, u32), ()> = HashMap::new();
356        let mut starts: Vec<(u32, u32)> = next.keys().copied().collect();
357        starts.sort_unstable();
358        for s in starts {
359            if seen.contains_key(&s) {
360                continue;
361            }
362            let mut ring = Vec::new();
363            let mut at = s;
364            loop {
365                seen.insert(at, ());
366                ring.push(flat(points[&at]));
367                match next.get(&at) {
368                    Some(&n) if n == s => break,
369                    Some(&n) if !seen.contains_key(&n) => at = n,
370                    _ => return None,
371                }
372            }
373            if ring.len() >= 3 {
374                loops.push(ring);
375            }
376        }
377
378        let measured: Vec<((f64, f64), f64)> = loops.iter().map(|l| area_centroid(l)).collect();
379        // The outer loop: the largest holding the origin. A plane through
380        // a point outside the solid may still cut it elsewhere, and that
381        // cut is not this station's section.
382        let outer = (0..loops.len())
383            .filter(|&i| contains(&loops[i], (0.0, 0.0)))
384            .max_by(|&a, &b| measured[a].1.abs().total_cmp(&measured[b].1.abs()))?;
385        let (mut sx, mut sy, mut sa) = {
386            let ((x, y), a) = measured[outer];
387            (x * a.abs(), y * a.abs(), a.abs())
388        };
389        for (i, ring) in loops.iter().enumerate() {
390            if i != outer && contains(&loops[outer], ring[0]) {
391                let ((x, y), a) = measured[i];
392                sx -= x * a.abs();
393                sy -= y * a.abs();
394                sa -= a.abs();
395            }
396        }
397        if sa <= 0.0 {
398            return None;
399        }
400        Some(origin + e1 * (sx / sa) + e2 * (sy / sa))
401    }
402}
403
404/// A polygon's area centroid and signed area.
405fn area_centroid(ring: &[(f64, f64)]) -> ((f64, f64), f64) {
406    let (mut a, mut cx, mut cy) = (0.0, 0.0, 0.0);
407    for i in 0..ring.len() {
408        let (p, q) = (ring[i], ring[(i + 1) % ring.len()]);
409        let w = p.0 * q.1 - q.0 * p.1;
410        a += w;
411        cx += (p.0 + q.0) * w;
412        cy += (p.1 + q.1) * w;
413    }
414    a *= 0.5;
415    if a == 0.0 {
416        return (ring[0], 0.0);
417    }
418    ((cx / (6.0 * a), cy / (6.0 * a)), a)
419}
420
421/// Whether a point lies inside a polygon, by crossing parity.
422fn contains(ring: &[(f64, f64)], p: (f64, f64)) -> bool {
423    let mut inside = false;
424    for i in 0..ring.len() {
425        let (a, b) = (ring[i], ring[(i + 1) % ring.len()]);
426        if (a.1 > p.1) != (b.1 > p.1) {
427            let x = a.0 + (p.1 - a.1) / (b.1 - a.1) * (b.0 - a.0);
428            if x > p.0 {
429                inside = !inside;
430            }
431        }
432    }
433    inside
434}