1#![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
22fn 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#[derive(Debug)]
38pub struct Section {
39 pub faces: Vec<FaceId>,
41}
42
43pub 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 dedup_coincident_segments(&mut segments, tol);
150
151 if segments.is_empty() {
155 let coplanar_segs = extract_coplanar_boundary(topo, &face_ids, normal, d, tol)?;
156 segments = coplanar_segs;
157 }
158
159 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 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 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
200fn 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 let polygons: Vec<Vec<Point3>> = wires.iter().map(|&wid| wire_vertices(topo, wid)).collect();
219
220 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 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 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
291fn 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
320fn 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
330fn 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
338fn 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
352fn 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 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 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 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
416fn 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 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 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
490fn 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 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 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 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 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 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 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 #[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 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 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 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 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 assert!(
789 (area - 6.0).abs() < 1e-6,
790 "cross-section area should be ~6.0, got {area}"
791 );
792 }
793
794 #[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 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 #[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 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 #[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 #[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}