1use std::cell::Cell;
57
58use geop_core_geometry::nurb_surface::NurbSurface3D;
59
60use geop_core_math::{
61 geop_error::{GeopError, GeopResult, WithContext},
62 matrix::{Matrix, symmetric_eigen3},
63 primitives::Pose,
64 quadrature::{Integral, MAX_POLYNOMIAL_DEGREE, Quadrature, integrate, integrate_polynomial},
65 scalars::Scalar,
66 vector::Vector3,
67};
68
69use crate::{FaceId, Model, SolidId, boundary::BoundaryType};
70
71const OUTER: Quadrature = Quadrature {
73 relative_tolerance: 1e-10,
74 max_panels: 64,
75};
76
77const INNER: Quadrature = Quadrature {
80 relative_tolerance: 1e-11,
81 max_panels: 32,
82};
83
84#[derive(Clone, Copy, Debug)]
93pub struct MassProperties {
94 pub volume: Scalar,
95 pub mass: Scalar,
98 pub center: Vector3,
99 pub inertia: [[Scalar; 3]; 3],
103 pub converged: bool,
104 pub evaluations: usize,
106}
107
108#[derive(Clone, Copy, Debug)]
112pub struct PrincipalAxes {
113 pub moments: [Scalar; 3],
114 pub axes: [Vector3; 3],
115}
116
117const SOLID_COMPONENTS: usize = 10;
122
123type PointAndNormal<'a> = dyn Fn(&Vector3, &Vector3) -> GeopResult<Vec<Scalar>> + 'a;
126
127#[derive(Clone, Copy)]
129struct FaceIntegrand<'a> {
130 components: usize,
131 g: &'a PointAndNormal<'a>,
132 degree: Option<fn(usize) -> usize>,
136}
137
138impl Model {
139 pub fn face_area(&self, face: FaceId) -> GeopResult<(Scalar, bool)> {
141 let ctx = |e: GeopError| e.with_context(format!("Model::face_area(face={face})"));
142 let area = FaceIntegrand {
143 components: 1,
144 g: &|_, n| Ok(vec![n.norm()]),
145 degree: None,
146 };
147 let integral = self.integrate_over_face(face, area).with_context(&ctx)?;
148 Ok((integral.value[0], integral.converged))
149 }
150
151 pub fn solid_area(&self, solid: SolidId) -> GeopResult<(Scalar, bool)> {
154 let ctx = |e: GeopError| e.with_context(format!("Model::solid_area(solid={solid})"));
155 let mut total = (Scalar::ZERO, true);
156 for face in self.solid_faces(solid).with_context(&ctx)? {
157 let (area, converged) = self.face_area(face).with_context(&ctx)?;
158 total = (total.0.add(area), total.1 && converged);
159 }
160 Ok(total)
161 }
162
163 pub fn mass_properties(&self, solid: SolidId) -> GeopResult<MassProperties> {
172 let ctx = |e: GeopError| e.with_context(format!("Model::mass_properties(solid={solid})"));
173 let faces = self.solid_faces(solid).with_context(&ctx)?;
174 let reference = self.reference_point(&faces).with_context(&ctx)?;
175 let mut total = [Scalar::ZERO; SOLID_COMPONENTS];
176 let mut converged = true;
177 let mut evaluations = 0;
178 let g = |p: &Vector3, n: &Vector3| {
179 let r = p.sub(&reference);
180 let (x, y, z) = (r[0], r[1], r[2]);
181 let half = |a: Scalar| a.div(Scalar::TWO);
182 let third = |a: Scalar| a.div(Scalar::from_f64(3.0));
183 Ok(vec![
184 third(r.prod_dot(n))?,
185 half(x.mul(x).mul(n[0]))?,
186 half(y.mul(y).mul(n[1]))?,
187 half(z.mul(z).mul(n[2]))?,
188 third(x.mul(x).mul(x).mul(n[0]))?,
189 third(y.mul(y).mul(y).mul(n[1]))?,
190 third(z.mul(z).mul(z).mul(n[2]))?,
191 half(x.mul(x).mul(y).mul(n[0]))?,
192 half(y.mul(y).mul(z).mul(n[1]))?,
193 half(z.mul(z).mul(x).mul(n[2]))?,
194 ])
195 };
196 let moments = FaceIntegrand {
197 components: SOLID_COMPONENTS,
198 g: &g,
199 degree: Some(|p| 5 * p - 1),
202 };
203 for &face in &faces {
204 let integral = self.integrate_over_face(face, moments).with_context(&ctx)?;
205 converged &= integral.converged;
206 evaluations += integral.evaluations;
207 for (t, v) in total.iter_mut().zip(integral.value) {
208 *t = t.add(v);
209 }
210 }
211 let [volume, hx, hy, hz, jxx, jyy, jzz, jxy, jyz, jzx] = total[..] else {
212 unreachable!("{SOLID_COMPONENTS} components");
213 };
214 if !volume.definitely_greater(Scalar::ZERO) {
215 return Err(ctx(GeopError::new(format!(
216 "its faces enclose a volume of {volume:?}, not one definitely greater than zero: \
217 they point into the material, or the solid is not closed"
218 ))));
219 }
220 let h = Vector3::from_array([hx, hy, hz]);
222 let offset = h.prod_scalar(Scalar::ONE.div(volume).with_context(&ctx)?);
223 let j = [[jxx, jxy, jzx], [jxy, jyy, jyz], [jzx, jyz, jzz]];
224 let mut jc = [[Scalar::ZERO; 3]; 3];
225 for a in 0..3 {
226 for b in 0..3 {
227 jc[a][b] = j[a][b].sub(h[a].mul(offset[b]));
228 }
229 }
230 let trace = jc[0][0].add(jc[1][1]).add(jc[2][2]);
231 let mut inertia = [[Scalar::ZERO; 3]; 3];
232 for a in 0..3 {
233 for b in 0..3 {
234 let delta = if a == b { trace } else { Scalar::ZERO };
235 inertia[a][b] = delta.sub(jc[a][b]);
236 }
237 }
238 Ok(MassProperties {
239 volume,
240 mass: volume,
241 center: reference.add(&offset),
242 inertia,
243 converged,
244 evaluations,
245 })
246 }
247
248 fn reference_point(&self, faces: &[FaceId]) -> GeopResult<Vector3> {
251 let mut sum = Vector3::zero();
252 for &face in faces {
253 let surface = &self.get_face(face)?.surface;
254 let ((u0, u1), (v0, v1)) = (surface.domain_u(), surface.domain_v());
255 let middle = surface.evaluate(
256 u0.add(u1).div(Scalar::TWO)?.sharpen(),
257 v0.add(v1).div(Scalar::TWO)?.sharpen(),
258 )?;
259 sum = sum.add(&middle);
260 }
261 let count = Scalar::from_i64(faces.len().max(1) as i64);
262 Ok(sum.prod_scalar(Scalar::ONE.div(count)?).sharpen())
263 }
264
265 fn integrate_over_face(
270 &self,
271 face_id: FaceId,
272 integrand: FaceIntegrand<'_>,
273 ) -> GeopResult<Integral> {
274 let FaceIntegrand {
275 components,
276 g,
277 degree,
278 } = integrand;
279 let face = self.get_face(face_id)?;
280 let surface = &face.surface;
281 let (along_u, polynomial) = match (
282 polynomial_along(surface, true),
283 polynomial_along(surface, false),
284 ) {
285 (true, true) => (surface.degree_u <= surface.degree_v, true),
286 (true, false) => (true, true),
287 (false, true) => (false, true),
288 (false, false) => (true, false),
289 };
290 let (breaks, p) = if along_u {
291 (surface.breakpoints_u(), surface.degree_u)
292 } else {
293 (surface.breakpoints_v(), surface.degree_v)
294 };
295 let exact = degree
296 .filter(|_| polynomial)
297 .map(|degree| degree(p))
298 .filter(|°ree| degree <= MAX_POLYNOMIAL_DEGREE);
299 let start = breaks[0];
300 let converged = Cell::new(true);
301 let evaluations = Cell::new(0);
302 let inner = |a: Scalar, b: Scalar| -> GeopResult<Vec<Scalar>> {
305 if a.is_sharp() && a.could_be_equal(start) {
308 return Ok(vec![Scalar::ZERO; components]);
309 }
310 let mut cuts = vec![start];
311 cuts.extend(breaks[1..].iter().filter(|k| k.definitely_less(a)));
312 cuts.push(a);
313 let along = |s: Scalar| -> GeopResult<Vec<Scalar>> {
314 let (u, v) = if along_u { (s, b) } else { (b, s) };
315 let point = surface.evaluate(u, v)?;
316 let (su, sv) = surface.derivatives(u, v)?;
317 g(&point, &su.prod_cross(&sv))
318 };
319 let integral = match exact {
320 Some(degree) => integrate_polynomial(along, &cuts, components, degree)?,
321 None => integrate(along, &cuts, components, &INNER)?,
322 };
323 if !integral.converged {
324 converged.set(false);
325 }
326 evaluations.set(evaluations.get() + integral.evaluations);
327 Ok(integral.value)
328 };
329 let mut total = vec![Scalar::ZERO; components];
330 for boundary in face.boundaries() {
331 let BoundaryType::Loop(anchor) = boundary else {
332 continue;
334 };
335 for coedge_id in self.iterate_loop_coedges(anchor) {
336 let pcurve = &self.get_coedge(coedge_id)?.pcurve;
337 let around = |t: Scalar| -> GeopResult<Vec<Scalar>> {
338 let uv = pcurve.evaluate(t)?;
339 let tangent = pcurve.tangent(t)?;
340 let (a, b, db) = if along_u {
342 (uv[0], uv[1], tangent[1])
343 } else {
344 (uv[1], uv[0], tangent[0].neg())
345 };
346 if db.is_sharp() && db.could_be_equal(Scalar::ZERO) {
349 return Ok(vec![Scalar::ZERO; components]);
350 }
351 Ok(inner(a, b)?
352 .into_iter()
353 .map(|value| value.mul(db))
354 .collect())
355 };
356 let integral = integrate(around, &pcurve.breakpoints(), components, &OUTER)
357 .with_context(&|e: GeopError| {
358 e.with_context(format!("along coedge {coedge_id} of face {face_id}"))
359 })?;
360 converged.set(converged.get() && integral.converged);
361 for (t, v) in total.iter_mut().zip(integral.value) {
362 *t = t.add(v);
363 }
364 }
365 }
366 Ok(Integral {
367 value: total,
368 converged: converged.get(),
369 evaluations: evaluations.get(),
370 })
371 }
372}
373
374fn polynomial_along(surface: &NurbSurface3D, along_u: bool) -> bool {
379 let (nu, nv) = (surface.num_u, surface.num_v);
380 let weight = |i: usize, j: usize| surface.control_points[i * nv + j][3];
381 let same = |a: Scalar, b: Scalar| a.is_subset_of(b) && b.is_subset_of(a);
382 if along_u {
383 (0..nv).all(|j| (1..nu).all(|i| same(weight(i, j), weight(0, j))))
384 } else {
385 (0..nu).all(|i| (1..nv).all(|j| same(weight(i, j), weight(i, 0))))
386 }
387}
388
389impl MassProperties {
390 pub fn with_density(&self, density: Scalar) -> Self {
393 Self {
394 mass: self.volume.mul(density),
395 inertia: self.inertia.map(|row| row.map(|i| i.mul(density))),
396 ..*self
397 }
398 }
399
400 pub fn placed(&self, pose: &Pose) -> GeopResult<Self> {
403 let motion = pose.motion();
404 let axes = [0, 1, 2].map(|k| motion.rotate(&Vector3::axis(k)));
405 let r = Matrix::<3, 3>::from_columns(axes);
407 let i = Matrix::from_rows(self.inertia);
408 let turned = r.mul_mat(&i).mul_mat(&r.transpose());
409 let mut inertia = [[Scalar::ZERO; 3]; 3];
410 for (a, row) in inertia.iter_mut().enumerate() {
411 for (b, value) in row.iter_mut().enumerate() {
412 *value = turned[(a, b)];
413 }
414 }
415 Ok(Self {
416 center: motion.apply(&self.center),
417 inertia,
418 ..*self
419 })
420 }
421
422 pub fn combine(parts: &[Self]) -> GeopResult<Option<Self>> {
426 let Some(first) = parts.first() else {
427 return Ok(None);
428 };
429 let sum =
430 |f: &dyn Fn(&Self) -> Scalar| parts.iter().skip(1).fold(f(first), |a, p| a.add(f(p)));
431 let mass = sum(&|p| p.mass);
432 let mut weighted = Vector3::zero();
433 for p in parts {
434 weighted = weighted.add(&p.center.prod_scalar(p.mass));
435 }
436 let center = weighted.prod_scalar(Scalar::ONE.div(mass).map_err(|e| {
437 e.with_context(format!(
438 "MassProperties::combine: a total mass of {mass:?} is not definitely positive"
439 ))
440 })?);
441 let mut inertia = [[Scalar::ZERO; 3]; 3];
442 for p in parts {
443 let d = p.center.sub(¢er);
444 let d2 = d.norm_sq();
445 for a in 0..3 {
446 for b in 0..3 {
447 let delta = if a == b { d2 } else { Scalar::ZERO };
448 let shift = delta.sub(d[a].mul(d[b])).mul(p.mass);
449 inertia[a][b] = inertia[a][b].add(p.inertia[a][b]).add(shift);
450 }
451 }
452 }
453 Ok(Some(Self {
454 volume: sum(&|p| p.volume),
455 mass,
456 center,
457 inertia,
458 converged: parts.iter().all(|p| p.converged),
459 evaluations: parts.iter().map(|p| p.evaluations).sum(),
460 }))
461 }
462
463 pub fn principal(&self) -> GeopResult<PrincipalAxes> {
465 let (moments, axes) = symmetric_eigen3(&Matrix::from_rows(self.inertia))?;
466 Ok(PrincipalAxes { moments, axes })
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use geop_core_math::scalars::Scalar;
473
474 use crate::{Model, test_fixtures::test_cube_solid};
475
476 #[test]
480 fn unit_cube() {
481 let mut model = Model::new();
482 let solid = test_cube_solid(&mut model);
483 let mass = model
484 .mass_properties(solid)
485 .unwrap()
486 .with_density(Scalar::TWO);
487 assert!(mass.converged);
488 assert!(mass.volume.could_be_equal(Scalar::ONE), "{:?}", mass.volume);
489 let (area, converged) = model.solid_area(solid).unwrap();
490 assert!(converged);
491 assert!(area.could_be_equal(Scalar::from_f64(6.0)), "{area:?}");
492 assert!(mass.mass.could_be_equal(Scalar::TWO));
493 let half = Scalar::from_f64(0.5);
494 for k in 0..3 {
495 assert!(mass.center[k].could_be_equal(half), "{:?}", mass.center);
496 for j in 0..3 {
497 let want = if j == k {
498 Scalar::from_ratio(1, 3).unwrap()
499 } else {
500 Scalar::ZERO
501 };
502 assert!(
503 mass.inertia[k][j].could_be_equal(want),
504 "{:?}",
505 mass.inertia
506 );
507 }
508 }
509 let (area, converged) = model
510 .face_area(*model.faces.keys().next().unwrap())
511 .unwrap();
512 assert!(converged);
513 assert!(area.could_be_equal(Scalar::ONE));
514 }
515}