1use brepkit_math::quadrature::gauss_legendre_points;
9use brepkit_math::traits::ParametricSurface;
10use brepkit_math::vec::{Point3, Vec3};
11use brepkit_topology::Topology;
12use brepkit_topology::edge::EdgeCurve;
13use brepkit_topology::face::{FaceId, FaceSurface};
14
15use crate::CheckError;
16
17#[derive(Debug, Clone)]
19pub struct FaceContribution {
20 pub area: f64,
22 pub volume: f64,
24 pub volume_moment_x: f64,
26 pub volume_moment_y: f64,
28 pub volume_moment_z: f64,
30 pub centroid_x: f64,
32 pub centroid_y: f64,
34 pub centroid_z: f64,
36}
37
38#[allow(clippy::too_many_lines)]
50pub fn integrate_face(
51 topo: &Topology,
52 face_id: FaceId,
53 gauss_order: usize,
54) -> Result<FaceContribution, CheckError> {
55 let face = topo.face(face_id)?;
56 let reversed = face.is_reversed();
57 let sign = if reversed { -1.0 } else { 1.0 };
58
59 match face.surface() {
60 FaceSurface::Plane { normal, .. } => {
61 let effective_normal = if reversed { -*normal } else { *normal };
62 integrate_planar_face(topo, face_id, effective_normal)
63 }
64 FaceSurface::Cylinder(s) => {
65 let full = (
66 (0.0, std::f64::consts::TAU),
67 (f64::NEG_INFINITY, f64::INFINITY),
68 );
69 let (u_range, v_range) = face_uv_bounds(topo, face_id, s, true, false, full)?;
70 let uv_boundary = build_face_uv_boundary(topo, face_id, |p| s.project_point(p), true)?;
71 Ok(integrate_with_trimming(
72 s,
73 u_range,
74 v_range,
75 gauss_order,
76 sign,
77 &uv_boundary,
78 true,
79 &[],
80 ))
81 }
82 FaceSurface::Cone(s) => {
83 let full = (
84 (0.0, std::f64::consts::TAU),
85 (f64::NEG_INFINITY, f64::INFINITY),
86 );
87 let (u_range, v_range) = face_uv_bounds(topo, face_id, s, true, false, full)?;
88 let uv_boundary = build_face_uv_boundary(topo, face_id, |p| s.project_point(p), true)?;
89 Ok(integrate_with_trimming(
90 s,
91 u_range,
92 v_range,
93 gauss_order,
94 sign,
95 &uv_boundary,
96 true,
97 &[],
98 ))
99 }
100 FaceSurface::Sphere(s) => {
101 let full = (
102 (0.0, std::f64::consts::TAU),
103 (-std::f64::consts::FRAC_PI_2, std::f64::consts::FRAC_PI_2),
104 );
105 let (u_range, v_range) = face_uv_bounds(topo, face_id, s, true, false, full)?;
106 let uv_boundary = build_face_uv_boundary(topo, face_id, |p| s.project_point(p), true)?;
107 let hole_vs = full_revolution_hole_vs(topo, face_id, s);
108 Ok(integrate_with_trimming(
109 s,
110 u_range,
111 v_range,
112 gauss_order,
113 sign,
114 &uv_boundary,
115 true,
116 &hole_vs,
117 ))
118 }
119 FaceSurface::Torus(s) => {
120 let full = ((0.0, std::f64::consts::TAU), (0.0, std::f64::consts::TAU));
121 let (u_range, v_range) = face_uv_bounds(topo, face_id, s, true, true, full)?;
122 let uv_boundary = build_face_uv_boundary(topo, face_id, |p| s.project_point(p), true)?;
123 Ok(integrate_with_trimming(
124 s,
125 u_range,
126 v_range,
127 gauss_order,
128 sign,
129 &uv_boundary,
130 true,
131 &[],
132 ))
133 }
134 FaceSurface::Nurbs(s) => {
135 let full = (s.domain_u(), s.domain_v());
136 let periodic_u = s.is_periodic_u();
137 let periodic_v = s.is_periodic_v();
138 let (u_range, v_range) =
139 face_uv_bounds(topo, face_id, s, periodic_u, periodic_v, full)?;
140 let uv_boundary =
141 build_face_uv_boundary(topo, face_id, |p| s.project_point(p), periodic_u)?;
142 Ok(integrate_with_trimming(
143 s,
144 u_range,
145 v_range,
146 gauss_order,
147 sign,
148 &uv_boundary,
149 periodic_u,
150 &[],
151 ))
152 }
153 }
154}
155
156type UvBounds = ((f64, f64), (f64, f64));
158
159fn full_revolution_hole_vs<S: ParametricSurface>(
169 topo: &Topology,
170 face_id: FaceId,
171 surface: &S,
172) -> Vec<f64> {
173 use std::f64::consts::TAU;
174 let Ok(face) = topo.face(face_id) else {
175 return Vec::new();
176 };
177 let mut out = Vec::new();
178 for &wid in face.inner_wires() {
179 let Ok(wire) = topo.wire(wid) else { continue };
180 let mut us = Vec::new();
181 let mut vs = Vec::new();
182 for oe in wire.edges() {
183 let Ok(edge) = topo.edge(oe.edge()) else {
184 continue;
185 };
186 let vid = if oe.is_forward() {
189 edge.start()
190 } else {
191 edge.end()
192 };
193 let Ok(v) = topo.vertex(vid) else {
194 continue;
195 };
196 let (u, vv) = surface.project_point(v.point());
197 us.push(u);
198 vs.push(vv);
199 }
200 if vs.is_empty() {
201 continue;
202 }
203 let v_min = vs.iter().copied().fold(f64::INFINITY, f64::min);
204 let v_max = vs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
205 if v_max - v_min > 1e-6 {
207 continue;
208 }
209 let unwrapped_span = {
214 let n = us.len();
215 let mut acc = 0.0;
216 for i in 0..n {
217 let d = us[(i + 1) % n] - us[i];
218 acc += d - TAU * ((d + std::f64::consts::PI) / TAU).floor();
219 }
220 acc.abs()
221 };
222 let single_closed_circle = wire.edges().len() == 1
223 && wire.edges().first().is_some_and(|oe| {
224 topo.edge(oe.edge())
225 .is_ok_and(|e| matches!(e.curve(), EdgeCurve::Circle(_)))
226 });
227 if unwrapped_span >= TAU - 1e-3 || single_closed_circle {
228 out.push(0.5 * (v_min + v_max));
229 }
230 }
231 out
232}
233
234fn face_uv_bounds<S: ParametricSurface>(
251 topo: &Topology,
252 face_id: FaceId,
253 surface: &S,
254 periodic_u: bool,
255 periodic_v: bool,
256 full_domain: UvBounds,
257) -> Result<UvBounds, CheckError> {
258 let face = topo.face(face_id)?;
259 let wire = topo.wire(face.outer_wire())?;
260
261 let mut uvs = Vec::new();
262 for oe in wire.edges() {
263 let edge = topo.edge(oe.edge())?;
264 let vid = oe.oriented_start(edge);
265 let pt = topo.vertex(vid)?.point();
266 uvs.push(surface.project_point(pt));
267 }
268
269 if uvs.is_empty() {
270 return Err(CheckError::IntegrationFailed(
271 "face wire has no edges".into(),
272 ));
273 }
274
275 if periodic_u || periodic_v {
278 for i in 1..uvs.len() {
279 if periodic_u {
280 uvs[i].0 = unwrap_angle(uvs[i - 1].0, uvs[i].0);
281 }
282 if periodic_v {
283 uvs[i].1 = unwrap_angle(uvs[i - 1].1, uvs[i].1);
284 }
285 }
286 }
287
288 let coincident = uvs.len() < 3 || {
290 let ref_uv = uvs[0];
291 uvs.iter()
292 .all(|uv| (uv.0 - ref_uv.0).abs() < 1e-6 && (uv.1 - ref_uv.1).abs() < 1e-6)
293 };
294 if coincident {
295 return Ok(full_domain);
296 }
297
298 let u_min = uvs.iter().map(|uv| uv.0).fold(f64::INFINITY, f64::min);
299 let mut u_max = uvs.iter().map(|uv| uv.0).fold(f64::NEG_INFINITY, f64::max);
300 let v_min = uvs.iter().map(|uv| uv.1).fold(f64::INFINITY, f64::min);
301 let mut v_max = uvs.iter().map(|uv| uv.1).fold(f64::NEG_INFINITY, f64::max);
302
303 if periodic_u && u_max - u_min < 1e-9 {
308 u_max = u_min + (full_domain.0.1 - full_domain.0.0);
309 }
310 if periodic_v && v_max - v_min < 1e-9 {
311 v_max = v_min + (full_domain.1.1 - full_domain.1.0);
312 }
313
314 if u_min >= u_max || v_min >= v_max {
315 return Ok(full_domain);
319 }
320
321 Ok(((u_min, u_max), (v_min, v_max)))
322}
323
324fn unwrap_angle(prev: f64, next: f64) -> f64 {
329 let tau = std::f64::consts::TAU;
330 let diff = next - prev;
331 prev + diff - tau * ((diff + std::f64::consts::PI) / tau).floor()
332}
333
334fn integrate_planar_face(
339 topo: &Topology,
340 face_id: FaceId,
341 normal: Vec3,
342) -> Result<FaceContribution, CheckError> {
343 let polygon = crate::util::face_polygon(topo, face_id)?;
344 let mut contrib = integrate_planar_polygon(&polygon, normal);
345
346 let face = topo.face(face_id)?;
347 let inner: Vec<_> = face.inner_wires().to_vec();
348 for wid in inner {
349 let hole = crate::util::wire_polygon(topo, wid)?;
350 let h = integrate_planar_polygon(&hole, normal);
351 contrib.area -= h.area;
352 contrib.volume -= h.volume;
353 contrib.volume_moment_x -= h.volume_moment_x;
354 contrib.volume_moment_y -= h.volume_moment_y;
355 contrib.volume_moment_z -= h.volume_moment_z;
356 contrib.centroid_x -= h.centroid_x;
357 contrib.centroid_y -= h.centroid_y;
358 contrib.centroid_z -= h.centroid_z;
359 }
360
361 Ok(contrib)
362}
363
364fn integrate_planar_polygon(polygon: &[Point3], normal: Vec3) -> FaceContribution {
366 if polygon.len() < 3 {
367 return FaceContribution {
368 area: 0.0,
369 volume: 0.0,
370 volume_moment_x: 0.0,
371 volume_moment_y: 0.0,
372 volume_moment_z: 0.0,
373 centroid_x: 0.0,
374 centroid_y: 0.0,
375 centroid_z: 0.0,
376 };
377 }
378
379 let mut area = 0.0;
388 let mut vol = 0.0;
389 let mut mx = 0.0;
390 let mut my = 0.0;
391 let mut mz = 0.0;
392 let mut cx = 0.0;
393 let mut cy = 0.0;
394 let mut cz = 0.0;
395
396 for i in 1..polygon.len() - 1 {
397 let (a, b, c) = (polygon[0], polygon[i], polygon[i + 1]);
398 let ab = b - a;
399 let ac = c - a;
400 let cross = Vec3::new(
401 ab.y() * ac.z() - ab.z() * ac.y(),
402 ab.z() * ac.x() - ab.x() * ac.z(),
403 ab.x() * ac.y() - ab.y() * ac.x(),
404 );
405 let tri_area = cross.dot(normal) * 0.5;
406 area += tri_area;
407
408 let centroid = Point3::new(
410 (a.x() + b.x() + c.x()) / 3.0,
411 (a.y() + b.y() + c.y()) / 3.0,
412 (a.z() + b.z() + c.z()) / 3.0,
413 );
414 let pv = Vec3::new(centroid.x(), centroid.y(), centroid.z());
415 vol += pv.dot(normal) * tri_area / 3.0;
416
417 let avg_x2 = (a.x() * a.x()
422 + b.x() * b.x()
423 + c.x() * c.x()
424 + a.x() * b.x()
425 + a.x() * c.x()
426 + b.x() * c.x())
427 / 6.0;
428 let avg_y2 = (a.y() * a.y()
429 + b.y() * b.y()
430 + c.y() * c.y()
431 + a.y() * b.y()
432 + a.y() * c.y()
433 + b.y() * c.y())
434 / 6.0;
435 let avg_z2 = (a.z() * a.z()
436 + b.z() * b.z()
437 + c.z() * c.z()
438 + a.z() * b.z()
439 + a.z() * c.z()
440 + b.z() * c.z())
441 / 6.0;
442 mx += 0.5 * avg_x2 * normal.x() * tri_area;
443 my += 0.5 * avg_y2 * normal.y() * tri_area;
444 mz += 0.5 * avg_z2 * normal.z() * tri_area;
445
446 cx += centroid.x() * tri_area;
447 cy += centroid.y() * tri_area;
448 cz += centroid.z() * tri_area;
449 }
450
451 let flip = if area < 0.0 { -1.0 } else { 1.0 };
455 FaceContribution {
456 area: area * flip,
457 volume: vol * flip,
458 volume_moment_x: mx * flip,
459 volume_moment_y: my * flip,
460 volume_moment_z: mz * flip,
461 centroid_x: cx * flip,
462 centroid_y: cy * flip,
463 centroid_z: cz * flip,
464 }
465}
466
467#[allow(clippy::cast_precision_loss)]
469fn integrate_parametric<S: ParametricSurface>(
470 surface: &S,
471 u_range: (f64, f64),
472 v_range: (f64, f64),
473 gauss_order: usize,
474 sign: f64,
475) -> FaceContribution {
476 const MAX_PATCHES: usize = 16;
485
486 let gauss_pts = gauss_legendre_points(gauss_order);
487 let patch = std::f64::consts::FRAC_PI_4;
488 let nu = (((u_range.1 - u_range.0).abs() / patch).ceil() as usize).clamp(1, MAX_PATCHES);
489 let nv = (((v_range.1 - v_range.0).abs() / patch).ceil() as usize).clamp(1, MAX_PATCHES);
490 let du_patch = (u_range.1 - u_range.0) / nu as f64;
491 let dv_patch = (v_range.1 - v_range.0) / nv as f64;
492 let u_scale = du_patch / 2.0;
493 let v_scale = dv_patch / 2.0;
494
495 let mut area = 0.0;
496 let mut vol = 0.0;
497 let mut mx = 0.0;
498 let mut my = 0.0;
499 let mut mz = 0.0;
500 let mut cx = 0.0;
501 let mut cy = 0.0;
502 let mut cz = 0.0;
503
504 for iu in 0..nu {
505 let u_mid = du_patch.mul_add(iu as f64, u_range.0) + u_scale;
506 for iv in 0..nv {
507 let v_mid = dv_patch.mul_add(iv as f64, v_range.0) + v_scale;
508 for gpu in gauss_pts {
509 let u = u_scale.mul_add(gpu.x, u_mid);
510 for gpv in gauss_pts {
511 let v = v_scale.mul_add(gpv.x, v_mid);
512 let w = gpu.w * gpv.w * u_scale * v_scale;
513
514 let p = surface.evaluate(u, v);
515 let du = surface.partial_u(u, v);
516 let dv = surface.partial_v(u, v);
517
518 let n = Vec3::new(
520 du.y() * dv.z() - du.z() * dv.y(),
521 du.z() * dv.x() - du.x() * dv.z(),
522 du.x() * dv.y() - du.y() * dv.x(),
523 );
524 let n_len = n.length();
525
526 area += w * n_len;
527
528 let pv = Vec3::new(p.x(), p.y(), p.z());
530 vol += w * pv.dot(n) / 3.0;
531
532 mx += w * 0.5 * p.x() * p.x() * n.x();
536 my += w * 0.5 * p.y() * p.y() * n.y();
537 mz += w * 0.5 * p.z() * p.z() * n.z();
538
539 cx += w * p.x() * n_len;
540 cy += w * p.y() * n_len;
541 cz += w * p.z() * n_len;
542 }
543 }
544 }
545 }
546
547 FaceContribution {
548 area,
549 volume: vol * sign,
550 volume_moment_x: mx * sign,
551 volume_moment_y: my * sign,
552 volume_moment_z: mz * sign,
553 centroid_x: cx,
554 centroid_y: cy,
555 centroid_z: cz,
556 }
557}
558
559fn polygon_area(poly: &[(f64, f64)]) -> f64 {
562 let n = poly.len();
563 if n < 3 {
564 return 0.0;
565 }
566 let mut a = 0.0;
567 for i in 0..n {
568 let (x0, y0) = poly[i];
569 let (x1, y1) = poly[(i + 1) % n];
570 a += x0 * y1 - x1 * y0;
571 }
572 (a * 0.5).abs()
573}
574
575#[allow(clippy::too_many_arguments)]
578fn integrate_with_trimming<S: ParametricSurface>(
579 surface: &S,
580 u_range: (f64, f64),
581 v_range: (f64, f64),
582 gauss_order: usize,
583 sign: f64,
584 uv_boundary: &[(f64, f64)],
585 u_periodic: bool,
586 hole_vs: &[f64],
587) -> FaceContribution {
588 if uv_boundary.len() < 3 {
589 return integrate_parametric(surface, u_range, v_range, gauss_order, sign);
590 }
591
592 let u_min = uv_boundary
601 .iter()
602 .map(|p| p.0)
603 .fold(f64::INFINITY, f64::min);
604 let v_min = uv_boundary
605 .iter()
606 .map(|p| p.1)
607 .fold(f64::INFINITY, f64::min);
608 let v_max = uv_boundary
609 .iter()
610 .map(|p| p.1)
611 .fold(f64::NEG_INFINITY, f64::max);
612
613 let tau = std::f64::consts::TAU;
618 let winding: f64 = (0..uv_boundary.len())
619 .map(|i| {
620 let d = uv_boundary[(i + 1) % uv_boundary.len()].0 - uv_boundary[i].0;
621 d - tau * ((d + std::f64::consts::PI) / tau).floor()
622 })
623 .sum();
624 let full_revolution = u_periodic && winding.abs() >= tau - 1e-3;
625 let v_degenerate = (v_max - v_min) <= 1e-9;
626
627 if full_revolution && v_degenerate {
628 let v_pole = if winding >= 0.0 { v_range.1 } else { v_range.0 };
633 let v_far = hole_vs
637 .iter()
638 .copied()
639 .filter(|&hv| (hv - v_min) * (v_pole - v_min) > 0.0 && (hv - v_min).abs() > 1e-9)
642 .min_by(|a, b| (a - v_min).abs().total_cmp(&(b - v_min).abs()))
643 .unwrap_or(v_pole);
644 let v_dom = (v_min.min(v_far), v_min.max(v_far));
645 integrate_parametric(surface, (u_min, u_min + tau), v_dom, gauss_order, sign)
646 } else if full_revolution {
647 integrate_parametric(
650 surface,
651 (u_min, u_min + tau),
652 (v_min, v_max),
653 gauss_order,
654 sign,
655 )
656 } else if polygon_area(uv_boundary) <= 1e-12 {
657 integrate_parametric(surface, u_range, v_range, gauss_order, sign)
660 } else {
661 integrate_parametric_trimmed(
662 surface,
663 u_range,
664 v_range,
665 gauss_order,
666 sign,
667 uv_boundary,
668 u_periodic,
669 )
670 }
671}
672
673#[allow(clippy::cast_precision_loss, clippy::too_many_lines)]
678fn integrate_parametric_trimmed<S: ParametricSurface>(
679 surface: &S,
680 u_range: (f64, f64),
681 v_range: (f64, f64),
682 gauss_order: usize,
683 sign: f64,
684 uv_boundary: &[(f64, f64)],
685 u_periodic: bool,
686) -> FaceContribution {
687 use brepkit_math::predicates::point_in_polygon;
688 use brepkit_math::vec::Point2;
689
690 let gauss_pts = gauss_legendre_points(gauss_order);
691 let u_scale = (u_range.1 - u_range.0) / 2.0;
692 let u_mid = f64::midpoint(u_range.0, u_range.1);
693 let v_scale = (v_range.1 - v_range.0) / 2.0;
694 let v_mid = f64::midpoint(v_range.0, v_range.1);
695
696 let uv_poly: Vec<Point2> = uv_boundary
697 .iter()
698 .map(|(u, v)| Point2::new(*u, *v))
699 .collect();
700
701 let u_bcenter = if u_periodic {
702 let bmin = uv_boundary
703 .iter()
704 .map(|(bu, _)| *bu)
705 .fold(f64::INFINITY, f64::min);
706 let bmax = uv_boundary
707 .iter()
708 .map(|(bu, _)| *bu)
709 .fold(f64::NEG_INFINITY, f64::max);
710 (bmin + bmax) * 0.5
711 } else {
712 0.0
713 };
714
715 let mut area = 0.0;
716 let mut vol = 0.0;
717 let mut mx = 0.0;
718 let mut my = 0.0;
719 let mut mz = 0.0;
720 let mut cx = 0.0;
721 let mut cy = 0.0;
722 let mut cz = 0.0;
723
724 for gpu in gauss_pts {
725 let u = u_scale.mul_add(gpu.x, u_mid);
726 for gpv in gauss_pts {
727 let v = v_scale.mul_add(gpv.x, v_mid);
728
729 let test_u = if u_periodic {
730 let tau = std::f64::consts::TAU;
731 let diff = u - u_bcenter;
732 u_bcenter + diff - tau * ((diff + std::f64::consts::PI) / tau).floor()
733 } else {
734 u
735 };
736
737 if !point_in_polygon(Point2::new(test_u, v), &uv_poly) {
738 continue;
739 }
740
741 let w = gpu.w * gpv.w * u_scale * v_scale;
742 let p = surface.evaluate(u, v);
743 let du = surface.partial_u(u, v);
744 let dv = surface.partial_v(u, v);
745 let n = Vec3::new(
746 du.y() * dv.z() - du.z() * dv.y(),
747 du.z() * dv.x() - du.x() * dv.z(),
748 du.x() * dv.y() - du.y() * dv.x(),
749 );
750 let n_len = n.length();
751
752 area += w * n_len;
753
754 let pv = Vec3::new(p.x(), p.y(), p.z());
755 vol += w * pv.dot(n) / 3.0;
756
757 mx += w * 0.5 * p.x() * p.x() * n.x();
758 my += w * 0.5 * p.y() * p.y() * n.y();
759 mz += w * 0.5 * p.z() * p.z() * n.z();
760
761 cx += w * p.x() * n_len;
762 cy += w * p.y() * n_len;
763 cz += w * p.z() * n_len;
764 }
765 }
766
767 FaceContribution {
768 area,
769 volume: vol * sign,
770 volume_moment_x: mx * sign,
771 volume_moment_y: my * sign,
772 volume_moment_z: mz * sign,
773 centroid_x: cx,
774 centroid_y: cy,
775 centroid_z: cz,
776 }
777}
778
779fn build_face_uv_boundary<F>(
784 topo: &Topology,
785 face_id: FaceId,
786 project: F,
787 u_periodic: bool,
788) -> Result<Vec<(f64, f64)>, CheckError>
789where
790 F: Fn(Point3) -> (f64, f64),
791{
792 let polygon = crate::util::face_polygon(topo, face_id)?;
793 if polygon.len() < 3 {
794 return Ok(vec![]);
795 }
796
797 let mut uv: Vec<(f64, f64)> = polygon.iter().map(|&p| project(p)).collect();
798
799 for i in 1..uv.len() {
800 if u_periodic {
801 uv[i].0 = unwrap_angle(uv[i - 1].0, uv[i].0);
802 }
803 }
804
805 Ok(uv)
806}
807
808#[cfg(test)]
809mod tests {
810 #![allow(clippy::unwrap_used, clippy::expect_used)]
811
812 use super::*;
813 use brepkit_math::vec::{Point3, Vec3};
814
815 #[test]
816 fn planar_fan_is_signed_on_nonconvex_polygons() {
817 let poly = [
821 Point3::new(0.0, 0.0, 2.0),
822 Point3::new(10.0, 0.0, 2.0),
823 Point3::new(10.0, 5.0, 2.0),
824 Point3::new(5.0, 5.0, 2.0),
825 Point3::new(5.0, 10.0, 2.0),
826 Point3::new(0.0, 10.0, 2.0),
827 ];
828 let up = Vec3::new(0.0, 0.0, 1.0);
829 let c = integrate_planar_polygon(&poly, up);
830 assert!((c.area - 75.0).abs() < 1e-9, "area {}", c.area);
831 assert!(
832 (c.volume - 2.0 * 75.0 / 3.0).abs() < 1e-9,
833 "vol {}",
834 c.volume
835 );
836
837 let rev: Vec<Point3> = poly.iter().rev().copied().collect();
840 let c2 = integrate_planar_polygon(&rev, up);
841 assert!((c2.area - 75.0).abs() < 1e-9, "rev area {}", c2.area);
842 }
843}