Skip to main content

cmtool_core/utils/
area.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3use super::polygon::{HalfSpace, Polygon};
4use crate::{
5    coordinates::*,
6    ensight_gold::types::{ElementsType, VolumeElementTypes},
7};
8
9// fn sort_polygon_ccw(points: &[[f64; 2]]) -> Vec<[f64; 2]> {
10//     let centroid = {
11//         let (mut sx, mut sy) = (0.0, 0.0);
12//         for p in points {
13//             sx += p[0];
14//             sy += p[1];
15//         }
16//         [sx / points.len() as f64, sy / points.len() as f64]
17//     };
18
19//     let mut sorted = points.to_vec();
20//     sorted.sort_by(|a, b| {
21//         let angle_a = (a[1] - centroid[1]).atan2(a[0] - centroid[0]);
22//         let angle_b = (b[1] - centroid[1]).atan2(b[0] - centroid[0]);
23//         angle_a.partial_cmp(&angle_b).unwrap()
24//     });
25//     sorted
26// }
27
28fn project_points_to_plane_2d(points: &[[f64; 3]], normal: &CartesianVec3) -> Vec<[f64; 2]> {
29    let n = normal.normalized();
30    let arbitrary = if n.0[0].abs() < n.0[2].abs() {
31        CartesianVec3([1.0, 0.0, 0.0])
32    } else {
33        CartesianVec3([0.0, 0.0, 1.0])
34    };
35    // let u = n.cross(&arbitrary).normalized();
36    // let v = u.cross(&n).normalized();
37    let v = n.cross(&arbitrary).normalized();
38    let u = v.cross(&n).normalized();
39
40    points
41        .iter()
42        .map(|p| CartesianVec3::from_point_origin(CartesianCoordinates(*p)))
43        .map(|p| [p.dot(&u), p.dot(&v)])
44        .collect()
45}
46
47// fn sort_points_ccw_3d(points: &[[f64; 3]], normal: &CartesianVec3) -> Vec<[f64; 3]> {
48//     let projected = project_points_to_plane_2d(points, normal);
49//     let sorted_2d = sort_polygon_ccw(&projected);
50
51//     let mut sorted_3d = Vec::with_capacity(points.len());
52
53//     for p2d in &sorted_2d {
54//         let idx = projected
55//             .iter()
56//             .enumerate()
57//             .min_by(|(_, a), (_, b)| {
58//                 let da = (a[0] - p2d[0]).hypot(a[1] - p2d[1]);
59//                 let db = (b[0] - p2d[0]).hypot(b[1] - p2d[1]);
60//                 da.partial_cmp(&db).unwrap()
61//             })
62//             .map(|(i, _)| i)
63//             .unwrap();
64//         sorted_3d.push(points[idx]);
65//     }
66
67//     sorted_3d
68// }
69//
70fn sort_points_ccw_3d(points: &[[f64; 3]], normal: &CartesianVec3) -> Vec<[f64; 3]> {
71    let projected = project_points_to_plane_2d(points, normal);
72    let mut indices: Vec<usize> = (0..points.len()).collect();
73    let centroid = {
74        let (mut sx, mut sy) = (0.0, 0.0);
75        for p in &projected {
76            sx += p[0];
77            sy += p[1];
78        }
79        [sx / projected.len() as f64, sy / projected.len() as f64]
80    };
81    indices.sort_by(|&a, &b| {
82        let angle_a = (projected[a][1] - centroid[1]).atan2(projected[a][0] - centroid[0]);
83        let angle_b = (projected[b][1] - centroid[1]).atan2(projected[b][0] - centroid[0]);
84        angle_a.partial_cmp(&angle_b).unwrap()
85    });
86
87    indices.iter().map(|&i| points[i]).collect()
88}
89
90///Index of the radial axis, the only face of a cylindrical compartment that is not a plane
91const RADIAL_AXIS: usize = 0;
92
93///Index of the axial axis, whose face is flat but bounded by two arcs
94const AXIAL_AXIS: usize = 2;
95
96///A radial face is a cylindrical patch, every other face of the compartment is flat
97pub fn is_curved_face(face: &BoundedPlane) -> bool {
98    face.axis == RADIAL_AXIS
99}
100
101///Plane to cut one element with, when the face it crosses is curved.
102///
103///`get_interface_plane` gives the plane tangent at the middle of the compartment, which drifts
104///away from the patch as theta moves:
105///
106///```text
107///      tangent at theta_c
108///     ------+------            an element sitting here never reaches the tangent plane,
109///      __--- ---__             it contributes no area at all
110///    _-     |     -_  <- patch
111///   /       |       \
112///          axis
113///```
114///
115///Taking the tangent at the angular position of the element keeps the error down to the curvature
116///over one element instead of over one compartment.
117pub fn tangent_plane_at(
118    patch: &BoundedPlane,
119    CartesianCoordinates(element_centroid): CartesianCoordinates,
120) -> BoundedPlane {
121    let CartesianCoordinates(patch_origin) = patch.origin;
122    let radius = patch_origin[0].hypot(patch_origin[1]);
123    let theta = element_centroid[1].atan2(element_centroid[0]);
124
125    BoundedPlane {
126        normal: CartesianVec3([theta.cos(), theta.sin(), 0.]),
127        origin: CartesianCoordinates([radius * theta.cos(), radius * theta.sin(), patch_origin[2]]),
128        extent_u: patch.extent_u,
129        extent_v: patch.extent_v,
130        axis: patch.axis,
131    }
132}
133
134///Half spaces bounding the face of a compartment, when all of them are planes.
135///So a radial and a theta face are exactly clippable by half spaces, an axial face is not: its
136///r bounds are cylinders, and that case is left to the caller.
137fn planar_bounds(plane: &BoundedPlane) -> Vec<HalfSpace> {
138    let z_bounds = |z0: f64, z1: f64| {
139        [
140            HalfSpace {
141                normal: CartesianVec3([0., 0., 1.]),
142                offset: z0,
143            },
144            HalfSpace {
145                normal: CartesianVec3([0., 0., -1.]),
146                offset: -z1,
147            },
148        ]
149    };
150
151    //Two half spaces can only describe a sector narrower than a half turn
152    let theta_bounds = |theta0: f64, theta1: f64| {
153        if theta1 - theta0 >= std::f64::consts::PI {
154            return None;
155        }
156        Some([
157            HalfSpace {
158                normal: CartesianVec3([-theta0.sin(), theta0.cos(), 0.]),
159                offset: 0.,
160            },
161            HalfSpace {
162                normal: CartesianVec3([theta1.sin(), -theta1.cos(), 0.]),
163                offset: 0.,
164            },
165        ])
166    };
167
168    let mut bounds = Vec::with_capacity(4);
169    match plane.axis {
170        //Radial face: theta and z bounds
171        0 => {
172            bounds.extend(
173                theta_bounds(plane.extent_u[0], plane.extent_u[1])
174                    .into_iter()
175                    .flatten(),
176            );
177            bounds.extend(z_bounds(plane.extent_v[0], plane.extent_v[1]));
178        }
179        //Theta face: radial and z bounds, the radial direction is the one of the face itself
180        1 => {
181            let CartesianCoordinates(origin) = plane.origin;
182            let theta = origin[1].atan2(origin[0]);
183            let radial = CartesianVec3([theta.cos(), theta.sin(), 0.]);
184
185            bounds.push(HalfSpace {
186                normal: radial,
187                offset: plane.extent_u[0],
188            });
189            bounds.push(HalfSpace {
190                normal: CartesianVec3([-radial.0[0], -radial.0[1], 0.]),
191                offset: -plane.extent_u[1],
192            });
193            bounds.extend(z_bounds(plane.extent_v[0], plane.extent_v[1]));
194        }
195        //Axial face: only the theta bounds are planes, the radial ones are arcs
196        _ => bounds.extend(
197            theta_bounds(plane.extent_v[0], plane.extent_v[1])
198                .into_iter()
199                .flatten(),
200        ),
201    }
202
203    bounds
204}
205
206///Signed area of the intersection between the triangle (origin, a, b) and the disk of radius
207///`radius` centred on the origin.
208///
209///Summed over the edges of a polygon it gives the area of that polygon clipped to the disk, the
210///same way the shoelace formula sums signed triangles. A piece of edge running outside the disk
211///contributes its circular sector instead of its triangle:
212///
213///```text
214///        b
215///       /                 outside -> sector of the circle
216///   ---+---___            inside  -> plain triangle
217///  /  p2       \
218/// |     \       |
219/// |      p1     |
220///  \      \    /
221///   ---    a---
222///```
223fn triangle_disk_area(a: [f64; 2], b: [f64; 2], radius: f64) -> f64 {
224    let cross = |u: [f64; 2], v: [f64; 2]| u[0] * v[1] - u[1] * v[0];
225    let dot = |u: [f64; 2], v: [f64; 2]| u[0] * v[0] + u[1] * v[1];
226    //Area swept on the circle between two directions
227    let sector = |u: [f64; 2], v: [f64; 2]| 0.5 * radius * radius * cross(u, v).atan2(dot(u, v));
228
229    let edge = [b[0] - a[0], b[1] - a[1]];
230    let quadratic_a = dot(edge, edge);
231    if quadratic_a < f64::EPSILON {
232        return 0.;
233    }
234    let quadratic_b = 2. * dot(a, edge);
235    let quadratic_c = dot(a, a) - radius * radius;
236    let discriminant = quadratic_b * quadratic_b - 4. * quadratic_a * quadratic_c;
237
238    if discriminant <= 0. {
239        return sector(a, b);
240    }
241
242    let root = discriminant.sqrt();
243    let entering = (-quadratic_b - root) / (2. * quadratic_a);
244    let leaving = (-quadratic_b + root) / (2. * quadratic_a);
245
246    //The edge crosses the circle outside of its own span
247    if entering > 1. || leaving < 0. {
248        return sector(a, b);
249    }
250
251    let at = |t: f64| [a[0] + t * edge[0], a[1] + t * edge[1]];
252    let entering_point = at(entering.clamp(0., 1.));
253    let leaving_point = at(leaving.clamp(0., 1.));
254
255    sector(a, entering_point)
256        + 0.5 * cross(entering_point, leaving_point)
257        + sector(leaving_point, b)
258}
259
260///Area of a polygon of the plane z = constant kept inside the annulus of the axial face
261fn polygon_annulus_area(polygon: &[Coords3], radii: [f64; 2]) -> f64 {
262    //A radius is never negative, whatever a caller passes as bounds
263    let radii = [radii[0].max(0.), radii[1].max(0.)];
264    let disk_area = |radius: f64| {
265        (0..polygon.len())
266            .map(|i| {
267                let current = polygon[i];
268                let next = polygon[(i + 1) % polygon.len()];
269                triangle_disk_area([current[0], current[1]], [next[0], next[1]], radius)
270            })
271            .sum::<f64>()
272    };
273
274    (disk_area(radii[1]) - disk_area(radii[0])).abs()
275}
276
277fn polygon_area_3d(points: &[Coords3], normal: &CartesianVec3) -> f64 {
278    let n = normal.normalized();
279
280    let mut area_vec = CartesianVec3([0.0, 0.0, 0.0]);
281    let n_pts = points.len();
282
283    for i in 0..n_pts {
284        let p1 = CartesianVec3(points[i]);
285        let p2 = CartesianVec3(points[(i + 1) % n_pts]);
286
287        let cross = p1.cross(&p2);
288
289        area_vec = area_vec.add(&cross);
290    }
291    0.5 * (area_vec.dot(&n)).abs()
292}
293
294fn tetra_area(vertices: [CartesianCoordinates; 4], plane: &BoundedPlane) -> f64 {
295    const REL_TOL_DISTANCE: f64 = 1e-6;
296    const EPSILON: f64 = 1e-12; //f64::EPSILON
297    let mut intersection_points = vec![];
298
299    let BoundedPlane {
300        normal,
301        origin: point,
302        ..
303    } = plane;
304
305    let d = -normal.dot(&CartesianVec3::from_point_origin(*point)); // plane offset
306    let distances: Vec<f64> = vertices
307        .iter()
308        .map(|coords| CartesianVec3::from_point_origin(*coords))
309        .map(|v| normal.dot(&v) + d)
310        .collect();
311
312    let mut points_on_plane = vec![];
313    let edge_len = (0..4)
314        .flat_map(|i| (i + 1..4).map(move |j| (i, j)))
315        .map(|(i, j)| {
316            let e = [
317                vertices[i].0[0] - vertices[j].0[0],
318                vertices[i].0[1] - vertices[j].0[1],
319                vertices[i].0[2] - vertices[j].0[2],
320            ];
321            (e[0] * e[0] + e[1] * e[1] + e[2] * e[2]).sqrt()
322        })
323        .fold(0.0_f64, f64::max);
324    if edge_len < EPSILON {
325        return 0.0;
326    }
327    let tol = REL_TOL_DISTANCE * edge_len;
328
329    for (i, dist) in distances.iter().enumerate() {
330        if dist.abs() < tol {
331            points_on_plane.push(vertices[i].0);
332        }
333    }
334
335    for i in 0..4 {
336        for j in (i + 1)..4 {
337            let d1 = distances[i];
338            let d2 = distances[j];
339            if d1.abs() < tol || d2.abs() < tol {
340                continue;
341            }
342            if d1 * d2 < 0.0 {
343                let t = d1.abs() / (d1.abs() + d2.abs());
344                let p1 = &vertices[i].0;
345                let p2 = &vertices[j].0;
346                let intersection = [
347                    p1[0] + t * (p2[0] - p1[0]),
348                    p1[1] + t * (p2[1] - p1[1]),
349                    p1[2] + t * (p2[2] - p1[2]),
350                ];
351                intersection_points.push(intersection);
352            }
353            // if d1 * d2 < 0.0 {
354            //     let t = d1.abs() / (d1.abs() + d2.abs());
355            //     let p1 = &vertices[i].0;
356            //     let p2 = &vertices[j].0;
357            //     let r1 = (p1[0].powi(2) + p1[1].powi(2)).sqrt();
358            //     let r2 = (p2[0].powi(2) + p2[1].powi(2)).sqrt();
359            //     let theta1 = p1[1].atan2(p1[0]);
360            //     let theta2 = p2[1].atan2(p2[0]);
361            //     let r_int = r1 + t * (r2 - r1);
362            //     let theta_int = theta1 + t * (theta2 - theta1);
363            //     intersection_points.push([
364            //         r_int * theta_int.cos(),
365            //         r_int * theta_int.sin(),
366            //         p1[2] + t * (p2[2] - p1[2]),
367            //     ]);
368            // }
369        }
370    }
371
372    for p in &points_on_plane {
373        let already_present = intersection_points.iter().any(|q| {
374            let dx = q[0] - p[0];
375            let dy = q[1] - p[1];
376            let dz = q[2] - p[2];
377            (dx * dx + dy * dy + dz * dz).sqrt() < tol
378        });
379        if !already_present {
380            intersection_points.push(*p);
381        }
382    }
383    // intersection_points.extend(points_on_plane.iter().cloned());
384
385    if intersection_points.len() < 3 {
386        return 0.0;
387    }
388
389    let sorted = sort_points_ccw_3d(&intersection_points, normal);
390
391    //Clip against the bounds of the face, an element straddling them contributes its share
392    let clipped = planar_bounds(plane)
393        .iter()
394        .fold(Polygon::from_slice(&sorted), |polygon, half_space| {
395            polygon.clip(half_space)
396        });
397
398    if clipped.as_slice().len() < 3 {
399        return 0.0;
400    }
401
402    //An axial face is bounded in r by two arcs, which no half space can describe
403    if plane.axis == AXIAL_AXIS {
404        return polygon_annulus_area(clipped.as_slice(), plane.extent_u);
405    }
406
407    polygon_area_3d(clipped.as_slice(), normal)
408}
409
410pub fn compute_intersection_area(
411    local_vertices: &[CartesianCoordinates],
412    elem_type: VolumeElementTypes,
413    plane: &BoundedPlane,
414) -> Option<f64> {
415    let tetra_indices = elem_type.tetra_subdivisions();
416    if local_vertices.len() != ElementsType::VolumeElementType(elem_type).node_count() as usize {
417        return None;
418    }
419    let area = tetra_indices
420        .iter()
421        .map(|&[i0, i1, i2, i3]| {
422            let a = local_vertices[i0];
423            let b = local_vertices[i1];
424            let c = local_vertices[i2];
425            let d = local_vertices[i3];
426            tetra_area([a, b, c, d], plane)
427        })
428        .sum();
429
430    Some(area)
431}
432
433#[cfg(test)]
434mod test {
435    use super::*;
436    fn make_bounded_plane(
437        normal: CartesianVec3,
438        origin: CartesianCoordinates,
439        axis: usize,
440    ) -> BoundedPlane {
441        // let (u, v) = orthonormal_basis(&normal);
442
443        let extent = [-10.0, 10.0];
444
445        BoundedPlane {
446            normal,
447            origin,
448            extent_u: extent,
449            extent_v: extent,
450            axis,
451        }
452    }
453
454    #[test]
455    fn test_intersection_area_r_plane() {
456        use std::f64::consts::PI;
457
458        let a = CartesianCoordinates::from(CylindricalCoordinates([1.0, 0.0, 0.0]));
459        let b = CartesianCoordinates::from(CylindricalCoordinates([1.0, PI / 2.0, 0.0]));
460        let c = CartesianCoordinates::from(CylindricalCoordinates([1.0, 0.0, 1.0]));
461        let d = CartesianCoordinates::from(CylindricalCoordinates([0., 0.0, 1.0]));
462
463        let ab = CartesianVec3::from_point(a, b);
464
465        let ac = CartesianVec3::from_point(a, c);
466
467        let cross_prod = ab.cross(&ac);
468
469        let normal = cross_prod.normalized();
470
471        let expected_area = 0.5
472            * (cross_prod.0[0].powi(2) + cross_prod.0[1].powi(2) + cross_prod.0[2].powi(2)).sqrt();
473
474        let plane = make_bounded_plane(normal, a, 0);
475
476        // let area = tetra_area([a, b, c, d], plane);
477
478        let area =
479            compute_intersection_area(&[a, b, c, d], VolumeElementTypes::Tetra4, &plane).unwrap();
480
481        assert!(
482            (area - expected_area).abs() < 1e-12,
483            "{} != {}",
484            area,
485            expected_area
486        );
487    }
488
489    #[test]
490    fn test_tetrahedron_intersection_area() {
491        // Define a simple tetrahedron with one vertex on each axis
492        let a = [0.0, 0.0, 0.0];
493        let b = [1.0, 0.0, 0.0];
494        let c = [0.0, 1.0, 0.0];
495        let d = [0.0, 0.0, 1.0];
496
497        let normal = CartesianVec3([0., 0., 1.]);
498        let point = CartesianCoordinates([0., 0., 0.5]);
499
500        let plane = make_bounded_plane(normal, point, 2);
501
502        // Calculate the intersection area
503        // let area = tetra_area(
504        //     CartesianCoordinates(a),
505        //     CartesianCoordinates(b),
506        //     CartesianCoordinates(c),
507        //     CartesianCoordinates(d),
508        //     value_on_ax,
509        //     axe_index,
510        // );
511
512        let area = tetra_area(
513            [
514                CartesianCoordinates(a),
515                CartesianCoordinates(b),
516                CartesianCoordinates(c),
517                CartesianCoordinates(d),
518            ],
519            &plane,
520        );
521
522        // Manually compute the triangle formed by slicing at z = 0.5
523        // The intersection points are:
524        // - a to d: [0, 0, 0] to [0, 0, 1] → [0, 0, 0.5]
525        // - b to d: [1, 0, 0] to [0, 0, 1] → [0.5, 0, 0.5]
526        // - c to d: [0, 1, 0] to [0, 0, 1] → [0, 0.5, 0.5]
527        //
528        // Projecting these onto the XY plane:
529        // [0.0, 0.0], [0.5, 0.0], [0.0, 0.5]
530        //
531        // Shoelace formula:
532        // Area = 0.5 * |(x1*y2 + x2*y3 + x3*y1) - (x2*y1 + x3*y2 + x1*y3)|
533        // Area = 0.5 * |(0*0 + 0.5*0.5 + 0*0) - (0.5*0 + 0*0.5 + 0*0.5)| = 0.125
534
535        let expected_area = 0.125;
536
537        assert!(
538            (area - expected_area).abs() < 1e-10,
539            "Expected area {}, got {}",
540            expected_area,
541            area
542        );
543    }
544
545    ///Theta face at theta = 0, so the face lies in the plane y = 0 and r is measured along x
546    fn theta_face(r: [f64; 2], z: [f64; 2]) -> BoundedPlane {
547        BoundedPlane {
548            normal: CartesianVec3([0., 1., 0.]),
549            origin: CartesianCoordinates([1., 0., 0.]),
550            extent_u: r,
551            extent_v: z,
552            axis: 1,
553        }
554    }
555
556    ///Tetra whose cut by y = 0 is the triangle (r,z) = (1,0), (2,0), (1,1), of area 1/2
557    ///   z
558    ///   1 +
559    ///     |\
560    ///     | \        cut of the tetra by the face
561    ///     |  \
562    ///   0 +---+---> r
563    ///     1   2
564    fn tetra_cut_by_theta_face() -> [CartesianCoordinates; 4] {
565        [
566            CartesianCoordinates([1., -1., 0.]),
567            CartesianCoordinates([3., -1., 0.]),
568            CartesianCoordinates([1., -1., 2.]),
569            CartesianCoordinates([1., 1., 0.]),
570        ]
571    }
572
573    ///A polygon wrapping the whole annulus recovers its exact area
574    #[test]
575    fn test_annulus_area_of_a_surrounding_polygon() {
576        let square = [[-5., -5., 0.], [5., -5., 0.], [5., 5., 0.], [-5., 5., 0.]];
577
578        let area = polygon_annulus_area(&square, [1., 2.]);
579        let expected = std::f64::consts::PI * (4. - 1.);
580
581        assert!((area - expected).abs() < 1e-9, "{} != {}", area, expected);
582    }
583
584    ///A polygon inside the hole of the annulus carries no area
585    #[test]
586    fn test_annulus_area_inside_the_hole() {
587        let square = [
588            [-0.2, -0.2, 0.],
589            [0.2, -0.2, 0.],
590            [0.2, 0.2, 0.],
591            [-0.2, 0.2, 0.],
592        ];
593
594        assert!(polygon_annulus_area(&square, [1., 2.]).abs() < 1e-12);
595    }
596
597    ///A polygon of the annulus itself keeps its plain area, the arcs cut nothing
598    #[test]
599    fn test_annulus_area_of_an_inner_polygon() {
600        let patch = [
601            [1.2, -0.1, 0.],
602            [1.8, -0.1, 0.],
603            [1.8, 0.1, 0.],
604            [1.2, 0.1, 0.],
605        ];
606
607        let area = polygon_annulus_area(&patch, [1., 2.]);
608
609        assert!((area - 0.12).abs() < 1e-9, "{} != 0.12", area);
610    }
611
612    #[test]
613    fn test_area_inside_bounds_is_kept_whole() {
614        let area = tetra_area(
615            tetra_cut_by_theta_face(),
616            &theta_face([0., 10.], [-10., 10.]),
617        );
618
619        assert!((area - 0.5).abs() < 1e-10, "expected 0.5, got {}", area);
620    }
621
622    ///An element straddling a bound used to be dropped, it now contributes its share.
623    ///Cutting the triangle at z = 0.5 leaves a trapezoid of area 1/2 - 1/8
624    #[test]
625    fn test_area_straddling_a_bound_is_clipped() {
626        let area = tetra_area(
627            tetra_cut_by_theta_face(),
628            &theta_face([0., 10.], [-10., 0.5]),
629        );
630
631        assert!((area - 0.375).abs() < 1e-10, "expected 0.375, got {}", area);
632    }
633
634    #[test]
635    fn test_area_outside_bounds_is_dropped() {
636        let area = tetra_area(
637            tetra_cut_by_theta_face(),
638            &theta_face([5., 10.], [-10., 10.]),
639        );
640
641        assert_eq!(area, 0.);
642    }
643
644    ///Patch of radius 1 spanning a 60 degree sector, tangent plane taken at its middle
645    fn radial_patch() -> BoundedPlane {
646        BoundedPlane {
647            normal: CartesianVec3([1., 0., 0.]),
648            origin: CartesianCoordinates([1., 0., 0.]),
649            extent_u: [-0.5, 0.5],
650            extent_v: [0., 1.],
651            axis: RADIAL_AXIS,
652        }
653    }
654
655    ///Small tetra straddling the cylinder r = 1 at theta = 0.4, far from the middle of the patch
656    fn element_away_from_the_middle() -> [CartesianCoordinates; 4] {
657        let theta = 0.4;
658        let point = |r: f64, dtheta: f64, z: f64| {
659            CartesianCoordinates([r * (theta + dtheta).cos(), r * (theta + dtheta).sin(), z])
660        };
661        [
662            point(0.95, -0.02, 0.4),
663            point(1.05, -0.02, 0.4),
664            point(0.95, 0.02, 0.4),
665            point(0.95, -0.02, 0.5),
666        ]
667    }
668
669    #[test]
670    fn test_tangent_plane_follows_the_element() {
671        let element = element_away_from_the_middle();
672        let centroid = CartesianCoordinates([
673            element
674                .iter()
675                .map(|CartesianCoordinates(p)| p[0])
676                .sum::<f64>()
677                / 4.,
678            element
679                .iter()
680                .map(|CartesianCoordinates(p)| p[1])
681                .sum::<f64>()
682                / 4.,
683            element
684                .iter()
685                .map(|CartesianCoordinates(p)| p[2])
686                .sum::<f64>()
687                / 4.,
688        ]);
689
690        let patch = radial_patch();
691        let plane = tangent_plane_at(&patch, centroid);
692
693        //The plane stays on the cylinder and keeps the bounds of the patch
694        let CartesianCoordinates(origin) = plane.origin;
695        assert!((origin[0].hypot(origin[1]) - 1.).abs() < 1e-12);
696        assert_eq!(plane.extent_u, patch.extent_u);
697        assert_eq!(plane.axis, patch.axis);
698
699        //The element is cut by its own tangent plane, the one of the compartment misses it
700        let with_element_plane =
701            compute_intersection_area(&element, VolumeElementTypes::Tetra4, &plane).unwrap();
702        let with_patch_plane =
703            compute_intersection_area(&element, VolumeElementTypes::Tetra4, &patch).unwrap();
704
705        assert!(
706            with_element_plane > 0.,
707            "the element must be cut by its own tangent plane"
708        );
709        assert_eq!(
710            with_patch_plane, 0.,
711            "the tangent plane of the compartment does not reach this element"
712        );
713    }
714
715    #[test]
716    fn test_sort_points_ccw_3d() {
717        let normal = CartesianVec3([0., 0., 1.]);
718        let points = vec![[0.5, 0.0, 0.5], [0.0, 0.5, 0.5], [0.0, 0.0, 0.5]];
719        let sorted = sort_points_ccw_3d(&points, &normal);
720        assert_eq!(sorted[0], [0.0, 0.0, 0.5]);
721        assert_eq!(sorted[1], [0.5, 0.0, 0.5]);
722        assert_eq!(sorted[2], [0.0, 0.5, 0.5]);
723    }
724}