Skip to main content

ogeom_mesh/
section.rs

1//! Sections of a triangle mesh by a plane: polylines that know which side
2//! the material is on.
3//!
4//! Which side of the plane each vertex lies on is decided once per vertex,
5//! by [`Exact::orient3d`] against three points spanning the plane, and every
6//! triangle reads its corners' sides from that one table. A triangle is cut
7//! exactly when its corners disagree, the cut runs between the two edges
8//! whose ends disagree, and two triangles sharing an edge agree on whether
9//! it is cut because they share both ends' sides. The chains therefore
10//! neither break nor double, whatever the rounding of the points on them.
11//!
12//! A vertex lying exactly in the plane counts as above it, on the side the
13//! normal points to: the section is the one the plane gives moved an
14//! infinitesimal distance against its normal. A cut edge whose upper end
15//! lies in the plane is cut at that vertex itself, and the cuts through one
16//! such vertex become one point of the chain. A triangle lying in the plane
17//! is not cut, and is listed in [`Section::coplanar`]: a face in the plane
18//! with the material below gives its outline, one with the material above
19//! gives nothing, as the moved plane would.
20//!
21//! Chains are welded by vertex index, through the edges two triangles share,
22//! never by position. Each runs with the material on its left seen from the
23//! side the normal points to, so an outer boundary runs counter-clockwise
24//! and a hole clockwise, and `outward` is the direction of travel crossed
25//! with the plane's normal.
26
27use ogeom_core::collections::FastMap;
28use ogeom_core::parallel::map_ordered;
29use ogeom_core::{Exact, OgeomResult, Predicates, Sign, ogeom_bail};
30use ogeom_math::{Plane, Point, Vector};
31use ogeom_topo::Triangulation;
32
33/// One connected polyline of a section.
34#[derive(Debug, Clone, PartialEq, Default)]
35pub struct Chain {
36    /// The points, in order along the chain. A closed chain does not repeat
37    /// its first point at its end.
38    pub points: Vec<Point>,
39    /// At each point, the unit direction in the plane pointing out of the
40    /// material, from the normals of the triangles cut there projected into
41    /// the plane.
42    pub outward: Vec<Vector>,
43    /// Whether the chain closes on itself: every edge it crosses is shared
44    /// by two triangles. An open chain ends on the mesh's boundary or on an
45    /// edge listed in [`Section::unlinked`].
46    pub closed: bool,
47}
48
49/// A mesh's section by one plane.
50#[derive(Debug, Clone, PartialEq, Default)]
51pub struct Section {
52    /// The chains, in the order of the lowest-numbered triangle each cuts.
53    pub chains: Vec<Chain>,
54    /// The triangles lying in the plane, every corner exactly on it, in
55    /// index order. None of them is cut.
56    pub coplanar: Vec<u32>,
57    /// The cut edges, as their two vertex indices smaller first, that are
58    /// not crossed once each way by the triangles using them: an edge of
59    /// more than two triangles, or of two wound the same way. A chain ends
60    /// on each rather than joining across it, and the edges are sorted.
61    pub unlinked: Vec<[u32; 2]>,
62}
63
64/// Section a mesh by a plane.
65///
66/// `outward` and the chains' direction come from the triangles' winding,
67/// counter-clockwise about their outward normals as
68/// [`Triangulation::triangles`] are. A triangle naming one vertex twice has
69/// no area and is passed over.
70///
71/// # Errors
72///
73/// [`OgeomError::Range`](ogeom_core::OgeomError::Range) if a triangle names
74/// a vertex the mesh does not have, and
75/// [`OgeomError::Numeric`](ogeom_core::OgeomError::Numeric) if a position or
76/// the plane's origin is not finite.
77pub fn section(mesh: &Triangulation, plane: &Plane) -> OgeomResult<Section> {
78    checked(mesh, plane)?;
79    Ok(cut(mesh, &Cutter::new(plane, 0.0)))
80}
81
82/// Section a mesh by a stack of planes, `plane` moved along its normal by
83/// each of `offsets` in turn.
84///
85/// Each section is [`section`]'s by the moved plane, computed in parallel
86/// and returned in the order of `offsets`, the same at any thread count.
87///
88/// # Errors
89///
90/// As [`section`], and [`OgeomError::Numeric`](ogeom_core::OgeomError::Numeric)
91/// if an offset is not finite.
92pub fn sections(mesh: &Triangulation, plane: &Plane, offsets: &[f64]) -> OgeomResult<Vec<Section>> {
93    checked(mesh, plane)?;
94    if let Some(d) = offsets.iter().find(|d| !d.is_finite()) {
95        ogeom_bail!(Numeric, "a section offset of {d} is not a distance");
96    }
97    Ok(map_ordered(offsets, |_, &d| {
98        cut(mesh, &Cutter::new(plane, d))
99    }))
100}
101
102fn checked(mesh: &Triangulation, plane: &Plane) -> OgeomResult<()> {
103    if !plane.origin().is_finite() {
104        ogeom_bail!(Numeric, "the section plane's origin is not finite");
105    }
106    if let Some(k) = mesh.positions.iter().position(|p| !p.is_finite()) {
107        ogeom_bail!(Numeric, "mesh vertex {k} is not finite");
108    }
109    let count = mesh.positions.len();
110    if let Some(&k) = mesh
111        .triangles
112        .iter()
113        .flatten()
114        .find(|&&k| k as usize >= count)
115    {
116        ogeom_bail!(Range, "a triangle names vertex {k} of {count}");
117    }
118    Ok(())
119}
120
121/// Below, in, or above the plane.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub(crate) enum Place {
124    Below,
125    On,
126    Above,
127}
128
129impl Place {
130    /// Whether the place counts as above: in the plane or past it.
131    pub(crate) const fn up(self) -> bool {
132        !matches!(self, Self::Below)
133    }
134}
135
136/// The plane as the side test sees it.
137#[derive(Debug, Clone, Copy)]
138pub(crate) struct Cutter {
139    origin: Point,
140    normal: Vector,
141    /// Two unit directions in the plane, `u` then `v` turning
142    /// counter-clockwise about `normal`.
143    axes: [Vector; 2],
144    /// Three points spanning the plane, counter-clockwise about `normal`.
145    span: [[f64; 3]; 3],
146}
147
148impl Cutter {
149    pub(crate) fn new(plane: &Plane, offset: f64) -> Self {
150        let z = plane.normal();
151        let normal = z.vector();
152        let origin = plane.origin() + normal * offset;
153        let u = z.any_perpendicular().vector();
154        let v = normal.cross(u);
155        Self {
156            origin,
157            normal,
158            axes: [u, v],
159            span: [
160                origin.to_array(),
161                (origin + u).to_array(),
162                (origin + v).to_array(),
163            ],
164        }
165    }
166
167    pub(crate) fn side(&self, p: Point) -> Place {
168        let [a, b, c] = self.span;
169        // Positive below: seen from below, the counter-clockwise span turns
170        // clockwise.
171        match Exact::orient3d(a, b, c, p.to_array()) {
172            Sign::Positive => Place::Below,
173            Sign::Zero => Place::On,
174            Sign::Negative => Place::Above,
175        }
176    }
177
178    fn height(&self, p: Point) -> f64 {
179        (p - self.origin).dot(self.normal)
180    }
181
182    /// The rounding of [`Cutter::along`] at `p`. Each coordinate of a point
183    /// `origin + w` is a dot product of `w`, rounded by at most `u`, the
184    /// unit roundoff, of each of its coordinates, with a unit axis, which
185    /// adds at most `γ₃ = 3u / (1 - 3u)` of the sum of its terms'
186    /// magnitudes: altogether within `(u + γ₃ (1 + u)) |w|₁`, under
187    /// `5u |w|₁`, of the exact one.
188    pub(crate) fn blur(&self, p: Point) -> f64 {
189        let w = p - self.origin;
190        5.0 * f64::EPSILON * (w.x.abs() + w.y.abs() + w.z.abs())
191    }
192
193    /// Whether `p` lies within the rounding of [`Cutter::along`] of its
194    /// foot on the plane, so that the two may take the same coordinates in
195    /// it: the foot, `p` moved by its height along the normal, is taken as
196    /// within [`Cutter::blur`] of `p`, at twice that.
197    pub(crate) fn near(&self, p: Point) -> bool {
198        self.height(p).abs() <= self.blur(p)
199    }
200
201    /// The plane's unit normal.
202    pub(crate) const fn normal(&self) -> Vector {
203        self.normal
204    }
205
206    /// Where `p`, as coordinates along the plane's two axes from its origin.
207    pub(crate) fn along(&self, p: Point) -> [f64; 2] {
208        let w = p - self.origin;
209        [w.dot(self.axes[0]), w.dot(self.axes[1])]
210    }
211
212    /// Where the edge from `low`, strictly below, to `high`, strictly
213    /// above, crosses the plane, interpolated from the lower end so that
214    /// every triangle sharing the edge computes the same bits.
215    pub(crate) fn crossing(&self, low: Point, high: Point) -> Point {
216        let (hp, hq) = (self.height(low), self.height(high));
217        // The exact sides put `low` strictly below and `high` strictly
218        // above; rounded heights a hair the wrong way are held to the edge,
219        // and two that round equal put the crossing anywhere on it, its
220        // middle as well as any.
221        let t = if hp == hq {
222            0.5
223        } else {
224            (hp / (hp - hq)).clamp(0.0, 1.0)
225        };
226        low.lerp(high, t)
227    }
228
229    /// How far [`Cutter::crossing`]'s point `x` on the edge from `low` to
230    /// `high` lies from the point at its parameter `t` on the edge. Each
231    /// coordinate of `high - low` is rounded by at most `u`, the unit
232    /// roundoff, its product with `t` by a further `u`, and the sum with
233    /// `low` by `u` of the sum: altogether within
234    /// `u |x| / (1 - u) + γ₂ t |high - low|`, under `2u (|x| + |high - low|)`,
235    /// of the exact one, and the distance under the sum of those over the
236    /// three coordinates.
237    pub(crate) fn crossing_blur(low: Point, high: Point, x: Point) -> f64 {
238        let d = high - low;
239        let l1 = x.x.abs() + x.y.abs() + x.z.abs() + d.x.abs() + d.y.abs() + d.z.abs();
240        f64::EPSILON * l1
241    }
242}
243
244/// A cut through one triangle, from the edge it leaves the lower side by
245/// to the edge it returns by, in the triangle's winding.
246#[derive(Debug, Clone, Copy)]
247struct Segment {
248    from: Edge,
249    to: Edge,
250    triangle: u32,
251}
252
253/// The cuts that start and end on one edge.
254#[derive(Debug, Clone, Copy)]
255struct Slot {
256    starts: u32,
257    ends: u32,
258    start: u32,
259    end: u32,
260}
261
262const NONE: u32 = u32::MAX;
263
264/// An edge, as its two vertex indices smaller first.
265type Edge = [u32; 2];
266
267fn key(a: u32, b: u32) -> Edge {
268    [a.min(b), a.max(b)]
269}
270
271/// One point of a chain: a vertex in the plane, or the crossing on an edge.
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273enum Node {
274    Vertex(u32),
275    Edge(Edge),
276}
277
278fn cut(mesh: &Triangulation, cutter: &Cutter) -> Section {
279    let sides: Vec<Place> = mesh.positions.iter().map(|&p| cutter.side(p)).collect();
280
281    let mut segments = Vec::new();
282    let mut coplanar = Vec::new();
283    for (index, t) in mesh.triangles.iter().enumerate() {
284        let triangle = u32::try_from(index).unwrap_or(NONE);
285        if t[0] == t[1] || t[1] == t[2] || t[2] == t[0] {
286            continue;
287        }
288        let s = t.map(|k| sides[k as usize]);
289        let ups = s.iter().filter(|s| s.up()).count();
290        if ups == 0 || ups == 3 {
291            if s.iter().all(|&s| s == Place::On) {
292                coplanar.push(triangle);
293            }
294            continue;
295        }
296        let (mut from, mut to) = ([0, 0], [0, 0]);
297        for k in 0..3 {
298            let (p, q) = (k, (k + 1) % 3);
299            match (s[p].up(), s[q].up()) {
300                (true, false) => from = key(t[p], t[q]),
301                (false, true) => to = key(t[p], t[q]),
302                _ => {}
303            }
304        }
305        segments.push(Segment { from, to, triangle });
306    }
307
308    let mut slots: FastMap<Edge, Slot> = FastMap::default();
309    let empty = Slot {
310        starts: 0,
311        ends: 0,
312        start: NONE,
313        end: NONE,
314    };
315    for (i, s) in segments.iter().enumerate() {
316        let i = u32::try_from(i).unwrap_or(NONE);
317        let slot = slots.entry(s.from).or_insert(empty);
318        slot.starts += 1;
319        slot.start = i;
320        let slot = slots.entry(s.to).or_insert(empty);
321        slot.ends += 1;
322        slot.end = i;
323    }
324    let linked = |slot: &Slot| slot.starts <= 1 && slot.ends <= 1;
325    let mut unlinked: Vec<[u32; 2]> = slots
326        .iter()
327        .filter(|(_, slot)| !linked(slot))
328        .map(|(&k, _)| k)
329        .collect();
330    unlinked.sort_unstable();
331
332    let next: Vec<u32> = segments
333        .iter()
334        .map(|s| {
335            slots
336                .get(&s.to)
337                .filter(|x| linked(x))
338                .map_or(NONE, |x| x.start)
339        })
340        .collect();
341    let prev: Vec<u32> = segments
342        .iter()
343        .map(|s| {
344            slots
345                .get(&s.from)
346                .filter(|x| linked(x))
347                .map_or(NONE, |x| x.end)
348        })
349        .collect();
350
351    let mut seen = vec![false; segments.len()];
352    let mut chains = Vec::new();
353    for first in 0..segments.len() {
354        if seen[first] {
355            continue;
356        }
357        let mut head = first;
358        let mut closed = false;
359        loop {
360            let p = prev[head];
361            if p == NONE {
362                break;
363            }
364            if p as usize == first {
365                closed = true;
366                head = first;
367                break;
368            }
369            head = p as usize;
370        }
371        let mut run = vec![head];
372        seen[head] = true;
373        let mut at = head;
374        loop {
375            let n = next[at];
376            if n == NONE || n as usize == head {
377                break;
378            }
379            at = n as usize;
380            seen[at] = true;
381            run.push(at);
382        }
383        if let Some(chain) = chain(mesh, cutter, &sides, &segments, &run, closed) {
384            chains.push(chain);
385        }
386    }
387
388    Section {
389        chains,
390        coplanar,
391        unlinked,
392    }
393}
394
395/// The chain through `run`, consecutive segments in order, or `None` where
396/// it has fewer than two distinct points.
397fn chain(
398    mesh: &Triangulation,
399    cutter: &Cutter,
400    sides: &[Place],
401    segments: &[Segment],
402    run: &[usize],
403    closed: bool,
404) -> Option<Chain> {
405    let node = |edge: Edge| {
406        let [a, b] = edge;
407        let up = if sides[a as usize].up() { a } else { b };
408        if sides[up as usize] == Place::On {
409            Node::Vertex(up)
410        } else {
411            Node::Edge(edge)
412        }
413    };
414    // Each segment's lean out of the material, none for one that a vertex
415    // in the plane shrinks to that vertex. A node takes the leans of the
416    // segments either side of it: in a closed chain the node a segment
417    // starts on joins it to the one before, in an open one the last
418    // segment's end is a node of its own.
419    let lean = |s: &Segment| {
420        if node(s.from) == node(s.to) {
421            Vector::ZERO
422        } else {
423            in_plane(mesh, cutter, s.triangle)
424        }
425    };
426    let leans: Vec<Vector> = run.iter().map(|&s| lean(&segments[s])).collect();
427    let mut nodes: Vec<(Node, Vector)> = Vec::with_capacity(run.len() + 1);
428    for (i, &s) in run.iter().enumerate() {
429        let before = if i > 0 {
430            leans[i - 1]
431        } else if closed && let Some(&last) = leans.last() {
432            last
433        } else {
434            Vector::ZERO
435        };
436        push(&mut nodes, node(segments[s].from), leans[i] + before);
437    }
438    if !closed && let (Some(&last), Some(&w)) = (run.last(), leans.last()) {
439        push(&mut nodes, node(segments[last].to), w);
440    }
441    if closed
442        && nodes.len() > 1
443        && let (Some(&(first, _)), Some(&(last, w))) = (nodes.first(), nodes.last())
444        && first == last
445    {
446        nodes.pop();
447        nodes[0].1 += w;
448    }
449    if nodes.len() < 2 {
450        return None;
451    }
452
453    let points: Vec<Point> = nodes
454        .iter()
455        .map(|&(n, _)| point(mesh, cutter, sides, n))
456        .collect();
457    let count = points.len();
458    let outward = (0..count)
459        .map(|i| {
460            let w = nodes[i].1;
461            let length = w.magnitude();
462            if length > 0.0 && length.is_finite() {
463                return w / length;
464            }
465            // No cut triangle here leans out of the plane by a representable
466            // amount: the chord through the neighbours, crossed with the
467            // normal, is the direction the material's edge runs.
468            let before = if i > 0 || closed {
469                (i + count - 1) % count
470            } else {
471                i
472            };
473            let after = if i + 1 < count || closed {
474                (i + 1) % count
475            } else {
476                i
477            };
478            let c = (points[after] - points[before]).cross(cutter.normal);
479            let length = c.magnitude();
480            if length > 0.0 && length.is_finite() {
481                c / length
482            } else {
483                Vector::ZERO
484            }
485        })
486        .collect();
487    Some(Chain {
488        points,
489        outward,
490        closed,
491    })
492}
493
494/// Append a node, merged into the last one where both are the same vertex.
495fn push(nodes: &mut Vec<(Node, Vector)>, node: Node, w: Vector) {
496    if let Some(last) = nodes.last_mut()
497        && last.0 == node
498    {
499        last.1 += w;
500        return;
501    }
502    nodes.push((node, w));
503}
504
505/// A triangle's unit normal with its component along the plane's normal
506/// removed: the sine of its tilt from the plane long, so the triangles
507/// nearest lying in the plane, whose projected normals are least well
508/// determined, weigh least.
509fn in_plane(mesh: &Triangulation, cutter: &Cutter, triangle: u32) -> Vector {
510    let [a, b, c] = mesh.triangles[triangle as usize].map(|k| mesh.positions[k as usize]);
511    let n = (b - a).cross(c - a);
512    let length = n.magnitude();
513    if !(length > 0.0 && length.is_finite()) {
514        return Vector::ZERO;
515    }
516    let n = n / length;
517    n - cutter.normal * n.dot(cutter.normal)
518}
519
520/// Where a node lies: the vertex itself, or the crossing on the edge.
521fn point(mesh: &Triangulation, cutter: &Cutter, sides: &[Place], node: Node) -> Point {
522    match node {
523        Node::Vertex(v) => mesh.positions[v as usize],
524        Node::Edge(edge) => {
525            let [a, b] = edge;
526            let (low, high) = if sides[a as usize].up() {
527                (b, a)
528            } else {
529                (a, b)
530            };
531            cutter.crossing(mesh.positions[low as usize], mesh.positions[high as usize])
532        }
533    }
534}
535
536#[cfg(test)]
537#[allow(clippy::unwrap_used, clippy::cast_precision_loss, reason = "test code")]
538mod tests {
539    use super::*;
540    use core::f64::consts::TAU;
541    use ogeom_core::Tolerances;
542    use ogeom_math::Direction;
543    use proptest::prelude::*;
544
545    const T: Tolerances = Tolerances::millimetres();
546
547    fn mesh(positions: Vec<Point>, triangles: Vec<[u32; 3]>) -> Triangulation {
548        Triangulation {
549            positions,
550            triangles,
551            ..Triangulation::new()
552        }
553    }
554
555    fn index(i: usize) -> u32 {
556        u32::try_from(i).unwrap()
557    }
558
559    /// A wall of `sides` facets about the `z` axis at `radius`, `rings`
560    /// rings of vertices from `z = 0` to `z = height`, wound outward when
561    /// `outward` and inward otherwise, its vertices appended to `points`.
562    fn wall(
563        points: &mut Vec<Point>,
564        triangles: &mut Vec<[u32; 3]>,
565        radius: f64,
566        height: f64,
567        sides: usize,
568        rings: usize,
569        outward: bool,
570    ) -> usize {
571        let base = points.len();
572        for j in 0..rings {
573            let z = height * j as f64 / (rings - 1) as f64;
574            for i in 0..sides {
575                let a = TAU * i as f64 / sides as f64;
576                points.push(Point::new(radius * a.cos(), radius * a.sin(), z));
577            }
578        }
579        let at = |i: usize, j: usize| index(base + j * sides + i % sides);
580        for j in 0..rings - 1 {
581            for i in 0..sides {
582                let (a, b, c, d) = (at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1));
583                if outward {
584                    triangles.extend([[a, b, c], [a, c, d]]);
585                } else {
586                    triangles.extend([[a, c, b], [a, d, c]]);
587                }
588            }
589        }
590        base
591    }
592
593    /// A closed cylinder about the `z` axis, capped by fans about the axis.
594    fn cylinder(radius: f64, height: f64, sides: usize, rings: usize) -> Triangulation {
595        let (mut points, mut triangles) = (Vec::new(), Vec::new());
596        let base = wall(
597            &mut points,
598            &mut triangles,
599            radius,
600            height,
601            sides,
602            rings,
603            true,
604        );
605        let top = base + (rings - 1) * sides;
606        let (low, high) = (index(points.len()), index(points.len() + 1));
607        points.extend([Point::new(0.0, 0.0, 0.0), Point::new(0.0, 0.0, height)]);
608        for i in 0..sides {
609            let j = (i + 1) % sides;
610            triangles.push([low, index(base + j), index(base + i)]);
611            triangles.push([high, index(top + i), index(top + j)]);
612        }
613        mesh(points, triangles)
614    }
615
616    /// A closed tube about the `z` axis between two radii, with annular caps.
617    fn tube(inner: f64, outer: f64, height: f64, sides: usize) -> Triangulation {
618        let (mut points, mut triangles) = (Vec::new(), Vec::new());
619        let o = wall(&mut points, &mut triangles, outer, height, sides, 2, true);
620        let i = wall(&mut points, &mut triangles, inner, height, sides, 2, false);
621        for k in 0..sides {
622            let m = (k + 1) % sides;
623            let (ok, om, ik, im) = (index(o + k), index(o + m), index(i + k), index(i + m));
624            triangles.extend([[ok, ik, im], [ok, im, om]]);
625            let s = index(sides);
626            triangles.extend([[ok + s, om + s, im + s], [ok + s, im + s, ik + s]]);
627        }
628        mesh(points, triangles)
629    }
630
631    /// The unit cube, each face a grid of `n` by `n` squares, welded and
632    /// wound outward.
633    fn cube(n: u32) -> Triangulation {
634        let mut ids: FastMap<[u32; 3], u32> = FastMap::default();
635        let (mut points, mut triangles) = (Vec::new(), Vec::new());
636        let scale = f64::from(n);
637        let mut vertex = |c: [u32; 3], points: &mut Vec<Point>| {
638            *ids.entry(c).or_insert_with(|| {
639                points.push(Point::new(
640                    f64::from(c[0]) / scale,
641                    f64::from(c[1]) / scale,
642                    f64::from(c[2]) / scale,
643                ));
644                index(points.len() - 1)
645            })
646        };
647        for axis in 0..3 {
648            for side in [0, n] {
649                let (u, v) = ((axis + 1) % 3, (axis + 2) % 3);
650                let at = |a: u32, b: u32| {
651                    let mut c = [0; 3];
652                    c[axis] = side;
653                    c[u] = a;
654                    c[v] = b;
655                    c
656                };
657                for a in 0..n {
658                    for b in 0..n {
659                        let q = [at(a, b), at(a + 1, b), at(a + 1, b + 1), at(a, b + 1)]
660                            .map(|c| vertex(c, &mut points));
661                        // `u` then `v` turns counter-clockwise about `+axis`.
662                        if side == n {
663                            triangles.extend([[q[0], q[1], q[2]], [q[0], q[2], q[3]]]);
664                        } else {
665                            triangles.extend([[q[0], q[2], q[1]], [q[0], q[3], q[2]]]);
666                        }
667                    }
668                }
669            }
670        }
671        mesh(points, triangles)
672    }
673
674    fn plane(origin: Point, normal: Vector) -> Plane {
675        Plane::through(origin, Direction::new(normal, T).unwrap())
676    }
677
678    fn horizontal(z: f64) -> Plane {
679        plane(Point::new(0.0, 0.0, z), Vector::Z)
680    }
681
682    /// The area a chain encloses, positive counter-clockwise about `normal`.
683    fn area(chain: &Chain, normal: Vector) -> f64 {
684        let p0 = chain.points[0];
685        let mut sum = 0.0;
686        for w in chain.points.windows(2).skip(1) {
687            sum += (w[0] - p0).cross(w[1] - p0).dot(normal);
688        }
689        sum * 0.5
690    }
691
692    fn radial(p: Point) -> Vector {
693        Vector::new(p.x, p.y, 0.0) / p.x.hypot(p.y)
694    }
695
696    #[test]
697    fn a_closed_cylinder_cut_across_is_one_circle_facing_away_from_its_axis() {
698        let sides = 48;
699        let m = cylinder(2.0, 3.0, sides, 4);
700        // Between rings, where the facets' diagonals are cut as well as
701        // their vertical edges, then through the second ring's vertices.
702        let polygon = 0.5 * sides as f64 * 4.0 * (TAU / sides as f64).sin();
703        let half = (TAU / sides as f64 / 2.0).cos();
704        for (z, count) in [(0.7, 2 * sides), (1.0, sides)] {
705            let s = section(&m, &horizontal(z)).unwrap();
706            assert!(s.unlinked.is_empty() && s.coplanar.is_empty());
707            assert_eq!(s.chains.len(), 1);
708            let c = &s.chains[0];
709            assert!(c.closed);
710            assert_eq!(c.points.len(), count);
711            assert_eq!(c.outward.len(), count);
712            let mut on_edges = 0;
713            for (p, w) in c.points.iter().zip(&c.outward) {
714                assert!((p.z - z).abs() < 1e-15, "{p:?}");
715                assert!(w.z.abs() < 1e-15);
716                if (p.x.hypot(p.y) - 2.0).abs() < 1e-14 {
717                    // On a vertical edge the two facets lean out equally.
718                    assert!(w.dot(radial(*p)) > 1.0 - 1e-12, "{w:?} at {p:?}");
719                    on_edges += 1;
720                } else {
721                    // Inside one facet, its own normal.
722                    assert!(w.dot(radial(*p)) > half - 1e-12, "{w:?} at {p:?}");
723                }
724            }
725            assert_eq!(on_edges, sides);
726            // Counter-clockwise about the normal: the regular polygon's area.
727            assert!((area(c, Vector::Z) - polygon).abs() < 1e-12);
728        }
729    }
730
731    #[test]
732    fn a_thin_wall_gives_two_chains_with_the_material_between_them() {
733        let sides = 64;
734        let (inner, outer) = (0.95, 1.0);
735        let s = section(&tube(inner, outer, 1.0, sides), &horizontal(0.5)).unwrap();
736        assert!(s.unlinked.is_empty());
737        assert_eq!(s.chains.len(), 2);
738        let polygon = |r: f64| 0.5 * sides as f64 * r * r * (TAU / sides as f64).sin();
739        // No direction out of a facet is further than half a facet's turn
740        // from the radius.
741        let half = (TAU / sides as f64 / 2.0).cos();
742        let mut faces = Vec::new();
743        for c in &s.chains {
744            assert!(c.closed);
745            assert_eq!(c.points.len(), 2 * sides);
746            let r = c.points.iter().map(|p| p.x.hypot(p.y)).fold(0.0, f64::max);
747            let facing: Vec<f64> = c
748                .points
749                .iter()
750                .zip(&c.outward)
751                .map(|(p, w)| w.dot(radial(*p)))
752                .collect();
753            if (r - outer).abs() < 1e-12 {
754                assert!(facing.iter().all(|&f| f > half - 1e-12));
755                assert!((area(c, Vector::Z) - polygon(outer)).abs() < 1e-12);
756                faces.push(1);
757            } else {
758                assert!((r - inner).abs() < 1e-12);
759                // The bore's side faces the axis, and runs clockwise.
760                assert!(facing.iter().all(|&f| f < -half + 1e-12));
761                assert!((area(c, Vector::Z) + polygon(inner)).abs() < 1e-12);
762                faces.push(-1);
763            }
764        }
765        faces.sort_unstable();
766        assert_eq!(faces, [-1, 1]);
767    }
768
769    #[test]
770    fn an_open_sheet_gives_an_open_chain_from_edge_to_edge() {
771        // A height field over [0, 4]², its normals up out of the material.
772        let n = 40;
773        let mut points = Vec::new();
774        for j in 0..=n {
775            for i in 0..=n {
776                let (x, y) = (4.0 * i as f64 / n as f64, 4.0 * j as f64 / n as f64);
777                points.push(Point::new(x, y, 0.1 * x.sin() * y.cos()));
778            }
779        }
780        let at = |i: usize, j: usize| index(j * (n + 1) + i);
781        let mut triangles = Vec::new();
782        for j in 0..n {
783            for i in 0..n {
784                let (a, b, c, d) = (at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1));
785                triangles.extend([[a, b, c], [a, c, d]]);
786            }
787        }
788        let m = mesh(points, triangles);
789        let s = section(&m, &plane(Point::new(2.05, 0.0, 0.0), Vector::X)).unwrap();
790        assert!(s.unlinked.is_empty());
791        assert_eq!(s.chains.len(), 1);
792        let c = &s.chains[0];
793        assert!(!c.closed);
794        // One point on each row of vertical edges and one on each diagonal.
795        assert_eq!(c.points.len(), 2 * n + 1);
796        // Material on the left seen from `+x`, below the sheet: it runs to `-y`.
797        assert_eq!(c.points[0].y, 4.0);
798        assert_eq!(c.points[2 * n].y, 0.0);
799        for (p, w) in c.points.iter().zip(&c.outward) {
800            assert!((p.x - 2.05).abs() < 1e-15);
801            assert!((p.z - 0.1 * p.x.sin() * p.y.cos()).abs() < 1e-3);
802            assert!(w.x.abs() < 1e-15 && w.z > 0.99, "{w:?}");
803        }
804    }
805
806    #[test]
807    fn a_plane_through_vertices_and_edges_passes_them_once() {
808        let m = cube(2);
809        // Through the ring of mid vertices: every edge of the ring lies in
810        // the plane, and each vertex is one point of the chain.
811        let s = section(&m, &horizontal(0.5)).unwrap();
812        assert!(s.unlinked.is_empty() && s.coplanar.is_empty());
813        assert_eq!(s.chains.len(), 1);
814        let c = &s.chains[0];
815        assert!(c.closed);
816        assert_eq!(c.points.len(), 8);
817        assert!(c.points.iter().all(|p| m.positions.contains(p)));
818        assert!((area(c, Vector::Z) - 1.0).abs() < 1e-15);
819        for (p, w) in c.points.iter().zip(&c.outward) {
820            let away = Vector::new(p.x - 0.5, p.y - 0.5, 0.0);
821            assert!(w.dot(away) > 0.0, "{w:?} at {p:?}");
822            assert!(w.z == 0.0);
823        }
824
825        // Along the top face: the plane moved down a hair cuts the sides
826        // just under it, so the outline of the face, which lies in it.
827        let s = section(&m, &horizontal(1.0)).unwrap();
828        assert_eq!(s.coplanar.len(), 8);
829        for &t in &s.coplanar {
830            assert!(
831                m.triangles[t as usize]
832                    .iter()
833                    .all(|&k| m.positions[k as usize].z == 1.0)
834            );
835        }
836        assert_eq!(s.chains.len(), 1);
837        assert_eq!(s.chains[0].points.len(), 8);
838        assert!((area(&s.chains[0], Vector::Z) - 1.0).abs() < 1e-15);
839
840        // Along the bottom face the moved plane misses the cube; facing the
841        // other way it moves up into it and finds the outline again.
842        let s = section(&m, &horizontal(0.0)).unwrap();
843        assert_eq!(s.coplanar.len(), 8);
844        assert!(s.chains.is_empty());
845        let s = section(&m, &plane(Point::ORIGIN, -Vector::Z)).unwrap();
846        assert_eq!(s.chains.len(), 1);
847        let c = &s.chains[0];
848        assert!((area(c, -Vector::Z) - 1.0).abs() < 1e-15);
849        for (p, w) in c.points.iter().zip(&c.outward) {
850            assert!(w.dot(Vector::new(p.x - 0.5, p.y - 0.5, 0.0)) > 0.0);
851        }
852
853        // Diagonally through two opposite vertical edges and the face
854        // diagonals between them.
855        let s = section(&m, &plane(Point::ORIGIN, Vector::new(1.0, -1.0, 0.0))).unwrap();
856        assert!(s.unlinked.is_empty());
857        assert_eq!(s.chains.len(), 1);
858        assert!(s.chains[0].closed);
859        assert!(
860            (area(&s.chains[0], Vector::new(1.0, -1.0, 0.0) / 2f64.sqrt()) - 2f64.sqrt()).abs()
861                < 1e-14
862        );
863    }
864
865    #[test]
866    fn every_lattice_plane_of_a_cube_cuts_it_once_or_not_at_all() {
867        // Each axis both ways, through every layer of the 4-by-4 grids:
868        // the faces the plane lies in are coplanar, and the plane moved a
869        // hair against its normal is inside the cube or not.
870        let m = cube(4);
871        for axis in 0..3 {
872            for sign in [1.0, -1.0] {
873                for layer in 0..=4 {
874                    let mut normal = [0.0; 3];
875                    normal[axis] = sign;
876                    let normal = Vector::from_array(normal);
877                    let mut origin = [0.5; 3];
878                    origin[axis] = f64::from(layer) / 4.0;
879                    let s = section(&m, &plane(Point::from_array(origin), normal)).unwrap();
880                    let inside = if sign > 0.0 { layer > 0 } else { layer < 4 };
881                    let face = layer == 0 || layer == 4;
882                    assert_eq!(s.coplanar.len(), if face { 32 } else { 0 });
883                    assert!(s.unlinked.is_empty());
884                    assert_eq!(s.chains.len(), usize::from(inside), "{axis} {sign} {layer}");
885                    if let Some(c) = s.chains.first() {
886                        assert!(c.closed);
887                        assert_eq!(c.points.len(), 16);
888                        assert!((area(c, normal) - 1.0).abs() < 1e-15);
889                    }
890                }
891            }
892        }
893    }
894
895    proptest! {
896        /// Any plane through the middle of a cube cuts it in one convex
897        /// loop, counter-clockwise about the normal, whose outward
898        /// directions all point away from its inside.
899        #[test]
900        fn a_plane_through_a_cube_cuts_one_convex_loop_facing_out(
901            (a, b) in (-3.2..3.2f64, 0.0..3.2f64),
902            at in (0.3..0.7f64, 0.3..0.7f64, 0.3..0.7f64),
903        ) {
904            let m = cube(3);
905            let normal = Vector::new(a.cos() * b.sin(), a.sin() * b.sin(), b.cos());
906            let s = section(&m, &plane(Point::new(at.0, at.1, at.2), normal)).unwrap();
907            prop_assert!(s.unlinked.is_empty() && s.coplanar.is_empty());
908            prop_assert_eq!(s.chains.len(), 1);
909            let c = &s.chains[0];
910            prop_assert!(c.closed);
911            prop_assert!(area(c, normal) > 0.0);
912            let centre = Point::centroid(&c.points).unwrap();
913            for (p, w) in c.points.iter().zip(&c.outward) {
914                prop_assert!(w.dot(*p - centre) > 0.0);
915                prop_assert!((w.magnitude() - 1.0).abs() < 1e-15);
916                prop_assert!(w.dot(normal).abs() < 1e-15);
917            }
918        }
919    }
920
921    #[test]
922    fn a_vertex_touching_the_plane_from_below_adds_no_chain() {
923        // A square pyramid with its apex in the plane.
924        let points = vec![
925            Point::new(-1.0, -1.0, 0.0),
926            Point::new(1.0, -1.0, 0.0),
927            Point::new(1.0, 1.0, 0.0),
928            Point::new(-1.0, 1.0, 0.0),
929            Point::new(0.0, 0.0, 1.0),
930        ];
931        let triangles = vec![
932            [0, 1, 4],
933            [1, 2, 4],
934            [2, 3, 4],
935            [3, 0, 4],
936            [0, 2, 1],
937            [0, 3, 2],
938        ];
939        let s = section(&mesh(points, triangles), &horizontal(1.0)).unwrap();
940        assert_eq!(s, Section::default());
941    }
942
943    #[test]
944    fn an_edge_of_three_sheets_is_reported_and_every_chain_ends_on_it() {
945        // Three fins about the `z` axis, sharing the edge from vertex 0 to 1.
946        let mut points = vec![Point::new(0.0, 0.0, -1.0), Point::new(0.0, 0.0, 1.0)];
947        let mut triangles = Vec::new();
948        for k in 0..3 {
949            let a = TAU * f64::from(k) / 3.0;
950            let (b, t) = (index(points.len()), index(points.len() + 1));
951            points.extend([
952                Point::new(a.cos(), a.sin(), -1.0),
953                Point::new(a.cos(), a.sin(), 1.0),
954            ]);
955            triangles.extend([[0, b, t], [0, t, 1]]);
956        }
957        let s = section(&mesh(points, triangles), &horizontal(0.0)).unwrap();
958        assert_eq!(s.unlinked, [[0, 1]]);
959        assert_eq!(s.chains.len(), 3);
960        for c in &s.chains {
961            assert!(!c.closed);
962            assert_eq!(c.points[0], Point::ORIGIN);
963            assert!((c.points.last().unwrap().to_vector().magnitude() - 1.0).abs() < 1e-15);
964        }
965
966        // Two triangles wound against each other across their shared edge.
967        let points = vec![
968            Point::new(0.0, 0.0, -1.0),
969            Point::new(0.0, 0.0, 1.0),
970            Point::new(1.0, 0.0, 0.0),
971            Point::new(-1.0, 0.0, 0.0),
972        ];
973        let s = section(&mesh(points, vec![[0, 2, 1], [0, 3, 1]]), &horizontal(0.0)).unwrap();
974        assert_eq!(s.unlinked, [[0, 1]]);
975        assert_eq!(s.chains.len(), 2);
976    }
977
978    #[test]
979    fn a_single_precision_mesh_is_cut_through_its_rings_exactly() {
980        // Rounding to single precision is the point: the mesh a binary STL holds.
981        #[allow(clippy::cast_possible_truncation)]
982        let single = |v: f64| f64::from(v as f32);
983        let mut m = cylinder(1.3, 1.0, 40, 11);
984        for p in &mut m.positions {
985            *p = Point::new(single(p.x), single(p.y), single(p.z));
986        }
987        let ring = single(0.3);
988        let s = section(&m, &horizontal(ring)).unwrap();
989        assert_eq!(s.chains.len(), 1);
990        let c = &s.chains[0];
991        assert!(c.closed && c.points.len() == 40);
992        assert!(
993            c.points
994                .iter()
995                .all(|p| p.z == ring && m.positions.contains(p))
996        );
997        // A hair off the ring, where the double-precision height falls.
998        let s = section(&m, &horizontal(0.3)).unwrap();
999        assert_eq!(s.chains.len(), 1);
1000        assert!(s.chains[0].closed && s.chains[0].points.len() == 80);
1001    }
1002
1003    #[test]
1004    fn a_stack_is_the_sections_of_the_moved_planes_in_order() {
1005        let m = cylinder(1.0, 2.0, 32, 5);
1006        let base = plane(Point::new(0.1, 0.2, 0.0), Vector::new(0.1, 0.2, 1.0));
1007        let offsets = [1.5, 0.25, 0.5, 1.0, -3.0];
1008        let stack = sections(&m, &base, &offsets).unwrap();
1009        assert_eq!(stack.len(), offsets.len());
1010        for (s, &d) in stack.iter().zip(&offsets) {
1011            let moved = Plane::through(base.origin() + base.normal().vector() * d, base.normal());
1012            assert_eq!(*s, section(&m, &moved).unwrap());
1013        }
1014        assert_eq!(stack[1].chains.len(), 1);
1015        assert!(stack[4].chains.is_empty());
1016        assert!(sections(&m, &base, &[f64::NAN]).is_err());
1017    }
1018
1019    #[test]
1020    fn a_bad_index_or_position_is_refused() {
1021        let mut m = cube(1);
1022        m.triangles.push([0, 1, 99]);
1023        assert!(section(&m, &horizontal(0.5)).is_err());
1024        let mut m = cube(1);
1025        m.positions[0].x = f64::NAN;
1026        assert!(section(&m, &horizontal(0.5)).is_err());
1027    }
1028}