Skip to main content

brepkit_operations/
section.rs

1//! Sectioning (slicing) solids with planes.
2//!
3//! Computes the cross-section of a solid at a given cutting plane,
4//! producing face(s) representing the intersection.
5
6#![allow(clippy::too_many_lines, clippy::doc_markdown)]
7
8use brepkit_math::tolerance::Tolerance;
9use brepkit_math::vec::{Point3, Vec3};
10use brepkit_topology::Topology;
11use brepkit_topology::edge::{Edge, EdgeCurve};
12use brepkit_topology::face::{Face, FaceId, FaceSurface};
13use brepkit_topology::solid::SolidId;
14use brepkit_topology::vertex::Vertex;
15use brepkit_topology::wire::{OrientedEdge, Wire, WireId};
16
17use brepkit_math::nurbs::intersection::IntersectionPoint;
18
19use crate::boolean::face_polygon;
20use crate::dot_normal_point;
21
22/// Chain intersection curve points into consecutive segment pairs.
23///
24/// Instead of connecting only the first and last point (chord approximation),
25/// this chains all intermediate points as individual segments, faithfully
26/// tracing the actual intersection curve.
27fn chain_curve_points(points: &[IntersectionPoint], segments: &mut Vec<(Point3, Point3)>) {
28    if points.len() < 2 {
29        return;
30    }
31    for pair in points.windows(2) {
32        segments.push((pair[0].point, pair[1].point));
33    }
34}
35
36/// A cross-section result: one or more planar faces on the cutting plane.
37#[derive(Debug)]
38pub struct Section {
39    /// The face IDs of the cross-section faces in the topology.
40    pub faces: Vec<FaceId>,
41}
42
43/// Compute the cross-section of a solid with a plane.
44///
45/// The cutting plane is defined by a point on the plane and its normal.
46/// Returns the cross-section as one or more planar faces lying on the
47/// cutting plane. For a simple convex solid this is typically one face;
48/// for solids with holes or disconnected volumes there may be multiple.
49/// A plane that misses the solid entirely yields an empty face list
50/// (a successful empty result, not an error).
51///
52/// # Algorithm
53///
54/// 1. For each face of the solid, compute the intersection with the
55///    cutting plane (line segments for planar faces).
56/// 2. Collect all intersection segments.
57/// 3. Assemble segments into closed wires.
58/// 4. Create planar faces from the wires.
59///
60/// # Errors
61///
62/// Returns an error if NURBS intersection computation fails, or if
63/// intersection segments exist but cannot be assembled into a closed
64/// cross-section wire.
65pub fn section(
66    topo: &mut Topology,
67    solid: SolidId,
68    plane_point: Point3,
69    plane_normal: Vec3,
70) -> Result<Section, crate::OperationsError> {
71    let tol = Tolerance::new();
72
73    let normal = plane_normal.normalize()?;
74    let d = dot_normal_point(normal, plane_point);
75
76    let solid_data = topo.solid(solid)?;
77    let all_shell_ids: Vec<_> = std::iter::once(solid_data.outer_shell())
78        .chain(solid_data.inner_shells().iter().copied())
79        .collect();
80
81    let mut face_ids: Vec<FaceId> = Vec::new();
82    for shell_id in &all_shell_ids {
83        let shell = topo.shell(*shell_id)?;
84        face_ids.extend_from_slice(shell.faces());
85    }
86
87    let mut segments: Vec<(Point3, Point3)> = Vec::new();
88
89    for &fid in &face_ids {
90        let face = topo.face(fid)?;
91        match face.surface() {
92            FaceSurface::Plane {
93                normal: face_normal,
94                d: face_d,
95            } => {
96                let face_normal = *face_normal;
97                let face_d = *face_d;
98
99                let verts = face_polygon(topo, fid)?;
100                if let Some(seg) =
101                    intersect_planar_face_with_plane(&verts, face_normal, face_d, normal, d, tol)
102                {
103                    segments.push(seg);
104                }
105            }
106            FaceSurface::Nurbs(nurbs) => {
107                let intersection_curves =
108                    brepkit_math::nurbs::intersection::intersect_plane_nurbs(nurbs, normal, d, 50)?;
109                for curve in &intersection_curves {
110                    chain_curve_points(&curve.points, &mut segments);
111                }
112            }
113            FaceSurface::Cylinder(cyl) => {
114                let curves =
115                    brepkit_math::analytic_intersection::intersect_plane_cylinder(cyl, normal, d)?;
116                for curve in &curves {
117                    chain_curve_points(&curve.points, &mut segments);
118                }
119            }
120            FaceSurface::Cone(cone) => {
121                let curves =
122                    brepkit_math::analytic_intersection::intersect_plane_cone(cone, normal, d)?;
123                for curve in &curves {
124                    chain_curve_points(&curve.points, &mut segments);
125                }
126            }
127            FaceSurface::Sphere(sphere) => {
128                let curves =
129                    brepkit_math::analytic_intersection::intersect_plane_sphere(sphere, normal, d)?;
130                for curve in &curves {
131                    chain_curve_points(&curve.points, &mut segments);
132                }
133            }
134            FaceSurface::Torus(torus) => {
135                let curves =
136                    brepkit_math::analytic_intersection::intersect_plane_torus(torus, normal, d)?;
137                for curve in &curves {
138                    chain_curve_points(&curve.points, &mut segments);
139                }
140            }
141        }
142    }
143
144    // Analytic faces can share a section curve across two oppositely-oriented
145    // faces — e.g. a sphere's two hemispheres each yield the full equatorial
146    // circle — emitting every segment twice in opposing directions. Left in
147    // place, the wire assembler chains both copies into a single zero-area
148    // double loop, so collapse coincident duplicates first.
149    dedup_coincident_segments(&mut segments, tol);
150
151    // If no crossing segments were found, the cutting plane may be exactly
152    // coplanar with one or more faces. In that case, extract the boundary
153    // edges of those coplanar faces as the cross-section.
154    if segments.is_empty() {
155        let coplanar_segs = extract_coplanar_boundary(topo, &face_ids, normal, d, tol)?;
156        segments = coplanar_segs;
157    }
158
159    // No crossing segments and no coplanar boundary across outer + inner
160    // shells means the cutting plane lies wholly on one side of the solid:
161    // a genuine miss. The empty set is a valid section, not an error.
162    if segments.is_empty() {
163        return Ok(Section { faces: Vec::new() });
164    }
165
166    let mut wires = assemble_wires(topo, &segments, normal, d, tol)?;
167
168    // Fallback: if crossing-based segments didn't form closed wires,
169    // try using coplanar face boundaries instead (handles the case where
170    // the cutting plane exactly coincides with a face of the solid).
171    if wires.is_empty() {
172        let coplanar_segs = extract_coplanar_boundary(topo, &face_ids, normal, d, tol)?;
173        if !coplanar_segs.is_empty() {
174            wires = assemble_wires(topo, &coplanar_segs, normal, d, tol)?;
175        }
176    }
177
178    if wires.is_empty() {
179        return Err(crate::OperationsError::InvalidInput {
180            reason: "no closed cross-section could be assembled".into(),
181        });
182    }
183
184    // Group wires by containment: outer wires get their inner wires as holes.
185    // Without this, a hollow shape's section would produce two separate faces
186    // (outer rectangle + inner hole rectangle) instead of one face-with-hole.
187    let groups = group_wires_by_containment(topo, &wires, normal);
188
189    let mut result_faces = Vec::with_capacity(groups.len());
190    for (outer, inners) in groups {
191        let face = topo.add_face(Face::new(outer, inners, FaceSurface::Plane { normal, d }));
192        result_faces.push(face);
193    }
194
195    Ok(Section {
196        faces: result_faces,
197    })
198}
199
200/// Group section wires by containment: each outer wire collects all wires that
201/// are spatially inside it as inner (hole) wires. Returns `(outer, [inner])` tuples.
202///
203/// A wire is "inner" when it sits inside another wire (in the section plane) and
204/// is not itself inside any wire that's already inside another. Two-level nesting
205/// (holes within holes within holes) is collapsed into the outermost containment;
206/// CAD sections don't typically produce deeper nesting.
207fn group_wires_by_containment(
208    topo: &Topology,
209    wires: &[WireId],
210    normal: Vec3,
211) -> Vec<(WireId, Vec<WireId>)> {
212    if wires.len() <= 1 {
213        return wires.iter().map(|&w| (w, vec![])).collect();
214    }
215
216    // Collect each wire's vertex polygon (used for both bounding-box and
217    // point-in-polygon checks below).
218    let polygons: Vec<Vec<Point3>> = wires.iter().map(|&wid| wire_vertices(topo, wid)).collect();
219
220    // For each wire, find the smallest wire that strictly contains it.
221    // A wire with no strict container is itself an outer wire.
222    let mut parent: Vec<Option<usize>> = vec![None; wires.len()];
223    for (i, poly_i) in polygons.iter().enumerate() {
224        if poly_i.is_empty() {
225            continue;
226        }
227        let sample = poly_i[0];
228        let mut best_parent: Option<usize> = None;
229        let mut best_area = f64::INFINITY;
230        for (j, poly_j) in polygons.iter().enumerate() {
231            if i == j || poly_j.len() < 3 {
232                continue;
233            }
234            if crate::distance::point_in_polygon_3d(&sample, poly_j, &normal) {
235                let area = polygon_area_3d(poly_j, normal);
236                if area < best_area {
237                    best_area = area;
238                    best_parent = Some(j);
239                }
240            }
241        }
242        parent[i] = best_parent;
243    }
244
245    // Build groups: each outer wire (no parent) collects its direct children.
246    let mut groups: Vec<(WireId, Vec<WireId>)> = Vec::new();
247    let mut group_of: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
248    for (i, &wid) in wires.iter().enumerate() {
249        if parent[i].is_none() {
250            group_of.insert(i, groups.len());
251            groups.push((wid, vec![]));
252        }
253    }
254    for (i, &wid) in wires.iter().enumerate() {
255        if let Some(p) = parent[i] {
256            // Walk up to the outermost ancestor (in case of nested holes).
257            let mut top = p;
258            while let Some(next) = parent[top] {
259                top = next;
260            }
261            if let Some(&group_idx) = group_of.get(&top) {
262                groups[group_idx].1.push(wid);
263            }
264        }
265    }
266
267    groups
268}
269
270fn wire_vertices(topo: &Topology, wire_id: WireId) -> Vec<Point3> {
271    let Ok(wire) = topo.wire(wire_id) else {
272        return vec![];
273    };
274    let mut pts = Vec::with_capacity(wire.edges().len());
275    for oe in wire.edges() {
276        let Ok(edge) = topo.edge(oe.edge()) else {
277            continue;
278        };
279        let vid = if oe.is_forward() {
280            edge.start()
281        } else {
282            edge.end()
283        };
284        if let Ok(v) = topo.vertex(vid) {
285            pts.push(v.point());
286        }
287    }
288    pts
289}
290
291/// Unsigned area of a 3D planar polygon (projected to dominant axis plane).
292fn polygon_area_3d(polygon: &[Point3], normal: Vec3) -> f64 {
293    if polygon.len() < 3 {
294        return 0.0;
295    }
296    let ax = normal.x().abs();
297    let ay = normal.y().abs();
298    let az = normal.z().abs();
299    let to_2d = |p: Point3| -> (f64, f64) {
300        if az >= ax && az >= ay {
301            (p.x(), p.y())
302        } else if ay >= ax {
303            (p.x(), p.z())
304        } else {
305            (p.y(), p.z())
306        }
307    };
308    let mut sum = 0.0;
309    let n = polygon.len();
310    for i in 0..n {
311        let (x0, y0) = to_2d(polygon[i]);
312        let (x1, y1) = to_2d(polygon[(i + 1) % n]);
313        sum += x0 * y1 - x1 * y0;
314    }
315    (sum * 0.5).abs()
316}
317
318type EdgeKey = ((i64, i64, i64), (i64, i64, i64));
319
320/// Quantize a point onto an integer lattice for tolerant endpoint matching.
321fn quantize_point(p: Point3, tol: Tolerance) -> (i64, i64, i64) {
322    let scale = 1.0 / (tol.linear * 10.0);
323    (
324        (p.x() * scale).round() as i64,
325        (p.y() * scale).round() as i64,
326        (p.z() * scale).round() as i64,
327    )
328}
329
330/// Build an orientation-insensitive key for a segment from its quantized
331/// endpoints, so `(a, b)` and `(b, a)` collide.
332fn make_edge_key(a: Point3, b: Point3, tol: Tolerance) -> EdgeKey {
333    let qa = quantize_point(a, tol);
334    let qb = quantize_point(b, tol);
335    if qa <= qb { (qa, qb) } else { (qb, qa) }
336}
337
338/// Collapse geometrically-coincident segments, ignoring orientation.
339///
340/// A section curve shared by two oppositely-oriented faces is emitted once in
341/// each direction (see the sphere-hemisphere case at the call site). Keying
342/// each segment by its unordered quantized endpoints keeps a single copy.
343/// Distinct polygon edges share at most one endpoint, so real edges of the
344/// section outline are never merged.
345fn dedup_coincident_segments(segments: &mut Vec<(Point3, Point3)>, tol: Tolerance) {
346    use std::collections::HashSet;
347
348    let mut seen: HashSet<EdgeKey> = HashSet::new();
349    segments.retain(|&(a, b)| seen.insert(make_edge_key(a, b, tol)));
350}
351
352/// Extract boundary edges of faces coplanar with the cutting plane.
353///
354/// For each coplanar face, its edges are boundary edges if they are not shared
355/// with another coplanar face. These boundary edges form the cross-section
356/// outline where the solid meets the cutting plane.
357fn extract_coplanar_boundary(
358    topo: &Topology,
359    face_ids: &[FaceId],
360    cut_normal: Vec3,
361    cut_d: f64,
362    tol: Tolerance,
363) -> Result<Vec<(Point3, Point3)>, crate::OperationsError> {
364    use std::collections::HashMap;
365
366    // Use a relaxed tolerance for coplanar detection to handle
367    // floating-point precision differences across platforms (e.g. WASM).
368    let coplanar_tol = tol.linear * 100.0;
369
370    let mut coplanar_faces = Vec::new();
371    for &fid in face_ids {
372        let verts = face_polygon(topo, fid)?;
373        if verts.len() >= 3
374            && verts
375                .iter()
376                .all(|v| (dot_normal_point(cut_normal, *v) - cut_d).abs() < coplanar_tol)
377        {
378            coplanar_faces.push(fid);
379        }
380    }
381
382    if coplanar_faces.is_empty() {
383        return Ok(Vec::new());
384    }
385
386    // Collect all edges of coplanar faces. Each edge is represented by a pair
387    // of quantized endpoint coordinates (to handle floating-point matching).
388    // An edge shared by two coplanar faces appears twice and is internal.
389    // An edge appearing once is a boundary edge.
390    let mut edge_counts: HashMap<EdgeKey, (Point3, Point3, usize)> = HashMap::new();
391
392    for &fid in &coplanar_faces {
393        let verts = face_polygon(topo, fid)?;
394        let n = verts.len();
395        for i in 0..n {
396            let a = verts[i];
397            let b = verts[(i + 1) % n];
398            let key = make_edge_key(a, b, tol);
399            edge_counts
400                .entry(key)
401                .and_modify(|e| e.2 += 1)
402                .or_insert((a, b, 1));
403        }
404    }
405
406    // Boundary edges appear exactly once.
407    let boundary: Vec<(Point3, Point3)> = edge_counts
408        .into_values()
409        .filter(|(_, _, count)| *count == 1)
410        .map(|(a, b, _)| (a, b))
411        .collect();
412
413    Ok(boundary)
414}
415
416/// Intersect a planar face polygon with a cutting plane.
417///
418/// Returns the line segment (if any) where the cutting plane crosses
419/// the face polygon. Uses the Sutherland-Hodgman approach: classify
420/// each vertex as above/below the plane, find edge crossings.
421fn intersect_planar_face_with_plane(
422    verts: &[Point3],
423    _face_normal: Vec3,
424    _face_d: f64,
425    cut_normal: Vec3,
426    cut_d: f64,
427    tol: Tolerance,
428) -> Option<(Point3, Point3)> {
429    let n = verts.len();
430    if n < 3 {
431        return None;
432    }
433
434    let dists: Vec<f64> = verts
435        .iter()
436        .map(|v| dot_normal_point(cut_normal, *v) - cut_d)
437        .collect();
438
439    // If all vertices lie on the cutting plane the face is coplanar —
440    // the cross-section boundary comes from adjacent faces, not this one.
441    // Use a relaxed tolerance (100× linear) for cross-platform consistency.
442    let coplanar_tol = tol.linear * 100.0;
443    if dists.iter().all(|d| d.abs() < coplanar_tol) {
444        return None;
445    }
446
447    let mut crossings = Vec::new();
448
449    for i in 0..n {
450        let j = (i + 1) % n;
451        let di = dists[i];
452        let dj = dists[j];
453
454        if di.abs() < tol.linear {
455            crossings.push(verts[i]);
456            continue;
457        }
458
459        // Check for sign change (edge crosses plane).
460        if (di > tol.linear && dj < -tol.linear) || (di < -tol.linear && dj > tol.linear) {
461            let t = di / (di - dj);
462            let pi = verts[i];
463            let pj = verts[j];
464            let ix = Point3::new(
465                (pj.x() - pi.x()).mul_add(t, pi.x()),
466                (pj.y() - pi.y()).mul_add(t, pi.y()),
467                (pj.z() - pi.z()).mul_add(t, pi.z()),
468            );
469            crossings.push(ix);
470        }
471    }
472
473    let mut unique = Vec::new();
474    for p in &crossings {
475        if !unique
476            .iter()
477            .any(|q: &Point3| (*p - *q).length_squared() < tol.linear * tol.linear)
478        {
479            unique.push(*p);
480        }
481    }
482
483    if unique.len() >= 2 {
484        Some((unique[0], unique[1]))
485    } else {
486        None
487    }
488}
489
490/// Assemble intersection segments into closed wires.
491///
492/// Chains segments endpoint-to-endpoint using spatial proximity,
493/// producing one or more closed wires.
494fn assemble_wires(
495    topo: &mut Topology,
496    segments: &[(Point3, Point3)],
497    _normal: Vec3,
498    _d: f64,
499    tol: Tolerance,
500) -> Result<Vec<WireId>, crate::OperationsError> {
501    if segments.is_empty() {
502        return Ok(vec![]);
503    }
504
505    let mut remaining: Vec<(Point3, Point3)> = segments.to_vec();
506    let mut wires = Vec::new();
507
508    // Chaining tolerance must be tight enough to keep separate loops apart
509    // (e.g., the outer perimeter and the inner hole of a hollow shape) while
510    // still tolerating the small endpoint drift produced by tessellated NURBS
511    // intersections. `tol.linear * 1000` (≈ 1e-4) balances both.
512    //
513    // Earlier this defaulted to 50% of the average segment length, which for
514    // a hollow 20×20 box section was ≈ 15 — wildly generous, bridging the
515    // outer and inner wires into one self-intersecting polygon.
516    let chain_tol = tol.linear * 1000.0;
517
518    while !remaining.is_empty() {
519        let first = remaining.remove(0);
520        let mut chain: Vec<Point3> = vec![first.0, first.1];
521
522        let mut changed = true;
523        while changed {
524            changed = false;
525            let chain_end = chain[chain.len() - 1];
526            let threshold_sq = chain_tol * chain_tol;
527
528            let mut best_idx = None;
529            let mut best_dist = threshold_sq;
530            let mut best_forward = true;
531
532            for i in 0..remaining.len() {
533                let (a, b) = remaining[i];
534                let dist_a = (a - chain_end).length_squared();
535                let dist_b = (b - chain_end).length_squared();
536
537                if dist_a < best_dist {
538                    best_dist = dist_a;
539                    best_idx = Some(i);
540                    best_forward = true;
541                }
542                if dist_b < best_dist {
543                    best_dist = dist_b;
544                    best_idx = Some(i);
545                    best_forward = false;
546                }
547            }
548
549            if let Some(idx) = best_idx {
550                let (a, b) = remaining.remove(idx);
551                chain.push(if best_forward { b } else { a });
552                changed = true;
553            }
554        }
555
556        if chain.len() < 3 {
557            continue;
558        }
559
560        let start = chain[0];
561        let end = chain[chain.len() - 1];
562        let closed = (start - end).length_squared() < chain_tol * chain_tol;
563
564        if !closed {
565            continue;
566        }
567
568        if chain.len() > 3 {
569            chain.pop();
570        }
571
572        let n = chain.len();
573        let vert_ids: Vec<_> = chain
574            .iter()
575            .map(|&p| topo.add_vertex(Vertex::new(p, tol.linear)))
576            .collect();
577
578        let mut oriented_edges = Vec::with_capacity(n);
579        for i in 0..n {
580            let j = (i + 1) % n;
581            let edge = topo.add_edge(Edge::new(vert_ids[i], vert_ids[j], EdgeCurve::Line));
582            oriented_edges.push(OrientedEdge::new(edge, true));
583        }
584
585        let wire = Wire::new(oriented_edges, true).map_err(crate::OperationsError::Topology)?;
586        wires.push(topo.add_wire(wire));
587    }
588
589    Ok(wires)
590}
591
592#[cfg(test)]
593mod tests {
594    #![allow(clippy::unwrap_used)]
595
596    use brepkit_math::vec::{Point3, Vec3};
597    use brepkit_topology::Topology;
598    use brepkit_topology::test_utils::make_unit_cube_manifold;
599
600    use super::*;
601
602    #[test]
603    fn section_cube_at_half_height() {
604        let mut topo = Topology::new();
605        let cube = make_unit_cube_manifold(&mut topo);
606
607        // Cut with a horizontal plane at z=0.5.
608        let result = section(
609            &mut topo,
610            cube,
611            Point3::new(0.0, 0.0, 0.5),
612            Vec3::new(0.0, 0.0, 1.0),
613        )
614        .unwrap();
615
616        assert_eq!(
617            result.faces.len(),
618            1,
619            "should produce one cross-section face"
620        );
621
622        // The cross section of a unit cube at z=0.5 should be a unit square.
623        let area = crate::measure::face_area(&topo, result.faces[0], 0.1).unwrap();
624        assert!(
625            (area - 1.0).abs() < 1e-6,
626            "cross-section area should be ~1.0, got {area}"
627        );
628    }
629
630    #[test]
631    fn section_cube_at_quarter_height() {
632        let mut topo = Topology::new();
633        let cube = make_unit_cube_manifold(&mut topo);
634
635        let result = section(
636            &mut topo,
637            cube,
638            Point3::new(0.0, 0.0, 0.25),
639            Vec3::new(0.0, 0.0, 1.0),
640        )
641        .unwrap();
642
643        assert_eq!(result.faces.len(), 1);
644
645        // Still a 1×1 square at any height for a cube.
646        let area = crate::measure::face_area(&topo, result.faces[0], 0.1).unwrap();
647        assert!(
648            (area - 1.0).abs() < 1e-6,
649            "cross-section area should be ~1.0, got {area}"
650        );
651    }
652
653    #[test]
654    fn section_cube_along_x() {
655        let mut topo = Topology::new();
656        let cube = make_unit_cube_manifold(&mut topo);
657
658        // Cut with a vertical plane at x=0.5.
659        let result = section(
660            &mut topo,
661            cube,
662            Point3::new(0.5, 0.0, 0.0),
663            Vec3::new(1.0, 0.0, 0.0),
664        )
665        .unwrap();
666
667        assert_eq!(result.faces.len(), 1);
668
669        // Cross-section of unit cube at x=0.5 is a 1×1 square in YZ.
670        let area = crate::measure::face_area(&topo, result.faces[0], 0.1).unwrap();
671        assert!(
672            (area - 1.0).abs() < 1e-6,
673            "cross-section area should be ~1.0, got {area}"
674        );
675    }
676
677    /// Section a box-minus-sphere at z=0.
678    ///
679    /// Box is (0,0,0)-(20,20,20), sphere at origin r=12.
680    /// At z=0, the sphere intersects the box creating a circular cutout.
681    /// Cross-section = 20×20 rectangle minus circle of radius 12
682    ///   (but sphere center is at origin and box starts at 0, so the
683    ///   sphere only clips a quarter-circle at the z=0 face corner).
684    ///
685    /// The exact area depends on how much of the sphere lies within the
686    /// box at this plane. At minimum, we verify the section succeeds and
687    /// produces a face with positive area less than the full 20×20 = 400.
688    #[test]
689    fn section_after_boolean_cut() {
690        let mut topo = Topology::new();
691        let b = crate::primitives::make_box(&mut topo, 20.0, 20.0, 20.0).unwrap();
692        let s = crate::primitives::make_sphere(&mut topo, 12.0, 16).unwrap();
693
694        let solid =
695            crate::boolean::boolean(&mut topo, crate::boolean::BooleanOp::Cut, b, s).unwrap();
696
697        let sec = section(
698            &mut topo,
699            solid,
700            Point3::new(0.0, 0.0, 0.0),
701            Vec3::new(0.0, 0.0, 1.0),
702        )
703        .unwrap();
704
705        assert!(!sec.faces.is_empty(), "should produce at least one face");
706
707        // The cross-section area should be positive and less than the full
708        // box face (400). The sphere removes a quarter-disc of radius 12
709        // from the corner, so area ≈ 400 - π(144)/4 ≈ 400 - 113.1 ≈ 286.9.
710        let total_area: f64 = sec
711            .faces
712            .iter()
713            .map(|&fid| crate::measure::face_area(&topo, fid, 0.1).unwrap())
714            .sum();
715        assert!(
716            total_area > 200.0,
717            "section area should be > 200 (box face minus sphere), got {total_area:.2}"
718        );
719        assert!(
720            total_area < 400.0,
721            "section area should be < 400 (full box face), got {total_area:.2}"
722        );
723    }
724
725    #[test]
726    fn section_plane_misses_solid() {
727        let mut topo = Topology::new();
728        let cube = make_unit_cube_manifold(&mut topo);
729
730        // Plane above the cube — misses entirely.
731        let result = section(
732            &mut topo,
733            cube,
734            Point3::new(0.0, 0.0, 5.0),
735            Vec3::new(0.0, 0.0, 1.0),
736        )
737        .unwrap();
738        assert!(
739            result.faces.is_empty(),
740            "plane above cube should produce an empty section"
741        );
742    }
743
744    #[test]
745    fn section_plane_flush_with_top_face() {
746        let mut topo = Topology::new();
747        let cube = make_unit_cube_manifold(&mut topo);
748
749        // Plane coincident with the cube's top face at z=1 — the coplanar
750        // boundary fallback must yield the face outline, not a miss.
751        let result = section(
752            &mut topo,
753            cube,
754            Point3::new(0.0, 0.0, 1.0),
755            Vec3::new(0.0, 0.0, 1.0),
756        )
757        .unwrap();
758        assert_eq!(
759            result.faces.len(),
760            1,
761            "flush plane should yield the face outline"
762        );
763        let area = crate::measure::face_area(&topo, result.faces[0], 0.1).unwrap();
764        assert!(
765            (area - 1.0).abs() < 1e-6,
766            "flush-face section area should be ~1.0, got {area}"
767        );
768    }
769
770    #[test]
771    fn section_extruded_box() {
772        let mut topo = Topology::new();
773        let solid = crate::primitives::make_box(&mut topo, 2.0, 3.0, 4.0).unwrap();
774
775        // Box extends from (0,0,0) to (2,3,4). Cut at z=2 (middle of the box).
776        let result = section(
777            &mut topo,
778            solid,
779            Point3::new(0.0, 0.0, 2.0),
780            Vec3::new(0.0, 0.0, 1.0),
781        )
782        .unwrap();
783
784        assert_eq!(result.faces.len(), 1);
785
786        let area = crate::measure::face_area(&topo, result.faces[0], 0.1).unwrap();
787        // 2×3 = 6
788        assert!(
789            (area - 6.0).abs() < 1e-6,
790            "cross-section area should be ~6.0, got {area}"
791        );
792    }
793
794    /// Diagonal section: plane x + y = 0.8, normal = (1,1,0).
795    ///
796    /// This plane intersects the unit cube at:
797    ///   front (y=0): x=0.8 → line (0.8,0,0)-(0.8,0,1)
798    ///   left (x=0): y=0.8 → line (0,0.8,0)-(0,0.8,1)
799    ///   top (z=1): x+y=0.8 → line (0.8,0,1)-(0,0.8,1)
800    ///   bottom (z=0): x+y=0.8 → line (0.8,0,0)-(0,0.8,0)
801    ///
802    /// The cross-section is a rectangle with:
803    ///   width = distance between the two vertical lines
804    ///         = |(0.8,0)-(0,0.8)| = √(0.64+0.64) = 0.8√2
805    ///   height = 1.0 (z extent)
806    ///   area = 0.8√2 ≈ 1.1314
807    #[test]
808    fn section_diagonal_plane() {
809        let mut topo = Topology::new();
810        let cube = make_unit_cube_manifold(&mut topo);
811
812        let result = section(
813            &mut topo,
814            cube,
815            Point3::new(0.4, 0.4, 0.5),
816            Vec3::new(1.0, 1.0, 0.0),
817        );
818
819        assert!(
820            result.is_ok(),
821            "diagonal plane should intersect cube: {:?}",
822            result.err()
823        );
824        let sec = result.unwrap();
825        assert_eq!(sec.faces.len(), 1);
826
827        // Area = width × height = 0.8√2 × 1.0 ≈ 1.1314
828        let area = crate::measure::face_area(&topo, sec.faces[0], 0.01).unwrap();
829        let expected = 0.8 * std::f64::consts::SQRT_2;
830        let rel_err = (area - expected).abs() / expected;
831        assert!(
832            rel_err < 1e-4,
833            "diagonal section area should be 0.8√2 ≈ {expected:.4}, got {area:.4} \
834             (rel_err={rel_err:.2e})"
835        );
836    }
837
838    /// Section of a cylinder at mid-height → circular cross-section.
839    /// Cylinder r=5, h=10, section at z=5 → circle area = πr² = 25π ≈ 78.54.
840    #[test]
841    fn section_cylinder_at_midheight() {
842        let mut topo = Topology::new();
843        let solid = crate::primitives::make_cylinder(&mut topo, 5.0, 10.0).unwrap();
844
845        let result = section(
846            &mut topo,
847            solid,
848            Point3::new(0.0, 0.0, 5.0),
849            Vec3::new(0.0, 0.0, 1.0),
850        );
851
852        assert!(
853            result.is_ok(),
854            "section of cylinder should succeed: {:?}",
855            result.err()
856        );
857        let sec = result.unwrap();
858        assert!(!sec.faces.is_empty(), "should produce at least one face");
859
860        let total_area: f64 = sec
861            .faces
862            .iter()
863            .map(|&fid| crate::measure::face_area(&topo, fid, 0.01).unwrap())
864            .sum();
865        // Circle area = πr² = 25π ≈ 78.54
866        let expected = std::f64::consts::PI * 25.0;
867        let rel_err = (total_area - expected).abs() / expected;
868        assert!(
869            rel_err < 0.05,
870            "cylinder section area should be πr² = {expected:.2}, got {total_area:.2} \
871             (rel_err={rel_err:.2e})"
872        );
873    }
874
875    /// Section of a sphere through its center → a disk bounded by the great
876    /// circle. A bare sphere is two hemisphere faces that each yield the full
877    /// equatorial circle, so the section must collapse the duplicate into one
878    /// disk rather than a zero-area double loop (#860). r=12 at z=0 → πr² =
879    /// 144π ≈ 452.39.
880    #[test]
881    fn section_sphere_through_center() {
882        let mut topo = Topology::new();
883        let solid = crate::primitives::make_sphere(&mut topo, 12.0, 16).unwrap();
884
885        let sec = section(
886            &mut topo,
887            solid,
888            Point3::new(0.0, 0.0, 0.0),
889            Vec3::new(0.0, 0.0, 1.0),
890        )
891        .unwrap();
892
893        assert_eq!(sec.faces.len(), 1, "sphere section should be a single disk");
894
895        let total_area: f64 = sec
896            .faces
897            .iter()
898            .map(|&fid| crate::measure::face_area(&topo, fid, 0.01).unwrap())
899            .sum();
900        let expected = std::f64::consts::PI * 144.0;
901        let rel_err = (total_area - expected).abs() / expected;
902        assert!(
903            rel_err < 0.05,
904            "sphere great-circle section area should be πr² = {expected:.2}, got \
905             {total_area:.2} (rel_err={rel_err:.2e})"
906        );
907    }
908
909    /// Section of a sphere off-center → a smaller disk. r=12 at z=5 →
910    /// circle radius √(144−25)=√119, area = 119π ≈ 373.85.
911    #[test]
912    fn section_sphere_off_center() {
913        let mut topo = Topology::new();
914        let solid = crate::primitives::make_sphere(&mut topo, 12.0, 16).unwrap();
915
916        let sec = section(
917            &mut topo,
918            solid,
919            Point3::new(0.0, 0.0, 5.0),
920            Vec3::new(0.0, 0.0, 1.0),
921        )
922        .unwrap();
923
924        assert_eq!(sec.faces.len(), 1, "sphere section should be a single disk");
925
926        let total_area: f64 = sec
927            .faces
928            .iter()
929            .map(|&fid| crate::measure::face_area(&topo, fid, 0.01).unwrap())
930            .sum();
931        let expected = std::f64::consts::PI * 119.0;
932        let rel_err = (total_area - expected).abs() / expected;
933        assert!(
934            rel_err < 0.05,
935            "sphere off-center section area should be {expected:.2}, got {total_area:.2} \
936             (rel_err={rel_err:.2e})"
937        );
938    }
939}