Skip to main content

geop_ops_inspect/
mass.rs

1//! [`mass_report`]: the mass properties of every solid a part shows, and of
2//! all of them together.
3
4use crate::{Scope, scope::Stopped};
5use geop_core_editor::output::{Answer, Block, Bounded, Row};
6use geop_core_editor::{editing::Tone, selectable::SelectableElement};
7use geop_core_math::{geop_error::GeopResult, scalars::Scalar};
8use geop_core_program::{
9    element::{ElementReference, PickedElement},
10    reader::Reader,
11    watch,
12};
13use geop_core_topology::mass::MassProperties;
14use geop_ops_model::placed::solids::{PlacedSolid, Shapes, placed_solids};
15use geop_ops_model::{Solid, model, parameters::Material};
16use serde::Serialize;
17
18/// Mass properties as shown, in millimetres and kilograms: the volume
19/// (mm³), the area of the boundary (mm²) and the centroid (mm) — what the
20/// shape decides — and, of a material, what it weighs ([`Inertial`]).
21#[derive(Clone, Debug, Serialize)]
22pub struct MassSummary {
23    pub volume: Bounded,
24    pub area: Bounded,
25    /// The centre of volume, which of a uniform material is the centre of
26    /// mass; of several solids, the mean weighted by mass where every one
27    /// has a material and by volume where none does. None for solids of
28    /// which only some have one: neither mean would be their centre.
29    pub center: Option<[Bounded; 3]>,
30    /// None where there is no material (or only some solids have one).
31    pub inertial: Option<Inertial>,
32    /// Whether every integral came within its tolerance (see
33    /// [`geop_core_math::quadrature`]): when not, the bounds are wider.
34    pub converged: bool,
35}
36
37/// What a body of a material weighs: its mass (kg), its inertia tensor
38/// about the centre of mass along the world's axes (kg·mm²), and the
39/// principal moments (kg·mm²) with their axes.
40#[derive(Clone, Debug, Serialize)]
41pub struct Inertial {
42    pub mass: Bounded,
43    pub inertia: [[Bounded; 3]; 3],
44    pub principal_moments: [Bounded; 3],
45    /// The axis of each principal moment, a unit vector — where moments
46    /// coincide, any axis of their plane is one, and these are a choice.
47    pub principal_axes: [[f64; 3]; 3],
48}
49
50/// How a body's — or bodies' — mass properties are known.
51#[derive(Clone, Copy)]
52pub(crate) enum Known {
53    /// Of a material: mass and inertia.
54    Weighed(MassProperties),
55    /// Of no material: of density one (see
56    /// [`geop_ops_model::placed::solids::PlacedSolid::shape_properties`]),
57    /// of which only the volume and the centroid are reported.
58    Shape(MassProperties),
59}
60
61impl MassSummary {
62    /// The summary of what is `known` of a body — or bodies — whose
63    /// boundary has the area `area`, with whether that area's quadrature
64    /// converged.
65    pub(crate) fn of(known: &Known, area: (Scalar, bool)) -> GeopResult<Self> {
66        let (p, inertial) = match known {
67            Known::Weighed(p) => {
68                let principal = p.principal()?;
69                let inertial = Inertial {
70                    mass: Bounded::of(p.mass),
71                    inertia: p.inertia.map(|row| row.map(Bounded::of)),
72                    principal_moments: principal.moments.map(Bounded::of),
73                    principal_axes: principal.axes.map(|axis| axis.to_f64_array()),
74                };
75                (p, Some(inertial))
76            }
77            Known::Shape(p) => (p, None),
78        };
79        Ok(Self {
80            volume: Bounded::of(p.volume),
81            area: Bounded::of(area.0),
82            center: Some([0, 1, 2].map(|k| Bounded::of(p.center[k]))),
83            inertial,
84            converged: p.converged && area.1,
85        })
86    }
87
88    /// As a table shows it: a row per property, in millimetres and
89    /// kilograms.
90    fn shown(&self) -> Block {
91        let mut rows = Vec::new();
92        if let Some(inertial) = &self.inertial {
93            rows.push(Row::labelled("Mass", vec![inertial.mass.show_mass()]));
94        }
95        rows.push(Row::labelled(
96            "Volume",
97            vec![format!("{} mm³", self.volume.show(3))],
98        ));
99        rows.push(Row::labelled(
100            "Area",
101            vec![format!("{} mm²", self.area.show(3))],
102        ));
103        if let Some(center) = &self.center {
104            let center: Vec<String> = center.iter().map(|c| c.show(3)).collect();
105            let label = match self.inertial {
106                Some(_) => "Centre",
107                None => "Centroid",
108            };
109            rows.push(Row::labelled(
110                label,
111                vec![format!("{} mm", center.join(", "))],
112            ));
113        }
114        if let Some(inertial) = &self.inertial {
115            rows.extend(inertial.shown());
116        }
117        Block::Table {
118            head: Vec::new(),
119            rows,
120            foot: Vec::new(),
121        }
122    }
123}
124
125impl Inertial {
126    /// Its inertia rows, as a table shows them.
127    fn shown(&self) -> [Row; 2] {
128        let axes = ["x", "y", "z"];
129        let inertia: Vec<String> = (axes.iter().zip(&self.inertia))
130            .map(|(axis, row)| {
131                let row: Vec<String> = row.iter().map(|c| format!("{:.5}", c.value)).collect();
132                format!("{axis}: {}", row.join("  "))
133            })
134            .collect();
135        let principal: Vec<String> = self.principal_moments.iter().map(|m| m.show(4)).collect();
136        let about: Vec<String> = (self.principal_axes.iter())
137            .map(|a| format!("({:.3}, {:.3}, {:.3})", a[0], a[1], a[2]))
138            .collect();
139        [
140            Row {
141                detail: Some("About the centre of mass, along the world's axes".into()),
142                ..Row::labelled("Inertia", vec![format!("{}\nkg·mm²", inertia.join("\n"))])
143            },
144            Row {
145                detail: Some(format!("about {}", about.join(", "))),
146                ..Row::labelled(
147                    "Principal",
148                    vec![format!("{}\nkg·mm²", principal.join("\n"))],
149                )
150            },
151        ]
152    }
153}
154
155/// One solid's mass properties, or why they could not be computed.
156#[derive(Clone, Debug, Serialize)]
157pub struct BodyMass {
158    /// The solid, named as the part shown names it.
159    pub name: String,
160    /// What it is made of: its part's material, none where it is not
161    /// given — then it has no mass, only a volume and a centroid.
162    pub material: Option<Material>,
163    pub properties: Option<MassSummary>,
164    pub error: Option<String>,
165    /// What is drawn of it, to light.
166    #[serde(skip)]
167    pub drawn: Vec<PickedElement>,
168}
169
170/// The mass properties of every solid a part shows, and of all of them.
171#[derive(Clone, Debug, Serialize)]
172pub struct MassReport {
173    pub bodies: Vec<BodyMass>,
174    /// All of them together — if there are any, and every one could be
175    /// computed: a total missing a body would mislead. What it says
176    /// depends on their materials (see [`MassSummary::center`]).
177    pub total: Option<MassSummary>,
178    /// How far it got, if it was stopped before the end: then there is no
179    /// total.
180    pub stopped: Option<Stopped>,
181}
182
183impl MassReport {
184    /// As the inspect panel shows it: all solids together, then each, lit
185    /// while pointed at, with its material — and that it has none, which
186    /// it needs to be weighed — and why it could not be computed.
187    pub fn shown(&self) -> Answer {
188        let mut blocks = Vec::new();
189        if let Some(Stopped { checked, of }) = self.stopped {
190            blocks.push(Block::Text {
191                text: format!("Stopped after {checked} of {of} solids: no total."),
192                tone: Tone::Error,
193            });
194        }
195        if self.bodies.is_empty() {
196            blocks.push(Block::Text {
197                text: "No solids.".into(),
198                tone: Tone::Hint,
199            });
200        }
201        if let Some(total) = self.total.as_ref().filter(|_| self.bodies.len() > 1) {
202            let mut shown = Vec::new();
203            if total.center.is_none() {
204                shown.push(Block::Text {
205                    text: SOME_MATERIAL.into(),
206                    tone: Tone::Hint,
207                });
208            }
209            shown.push(total.shown());
210            blocks.push(Block::Group {
211                title: format!("All {} solids", self.bodies.len()),
212                detail: None,
213                open: true,
214                light: Vec::new(),
215                blocks: shown,
216            });
217        }
218        for body in &self.bodies {
219            let mut shown = Vec::new();
220            if body.material.is_none() {
221                shown.push(Block::Text {
222                    text: NO_MATERIAL.into(),
223                    tone: Tone::Hint,
224                });
225            }
226            shown.extend(body.error.iter().map(|error| Block::Text {
227                text: error.clone(),
228                tone: Tone::Error,
229            }));
230            shown.extend(body.properties.iter().map(MassSummary::shown));
231            blocks.push(Block::Group {
232                title: body.name.clone(),
233                detail: Some(
234                    body.material
235                        .as_ref()
236                        .map_or("None".into(), |m| m.name.clone()),
237                ),
238                open: self.bodies.len() == 1,
239                light: body.drawn.clone(),
240                blocks: shown,
241            });
242        }
243        Answer {
244            blocks,
245            plane: None,
246        }
247    }
248}
249
250/// The hint on a body of no material.
251const NO_MATERIAL: &str = "No material: set one under Properties to get mass and inertia.";
252
253/// The hint on a total of solids only some of which have a material.
254const SOME_MATERIAL: &str =
255    "Only some solids have a material: of all of them, only their volume and area.";
256
257/// The mass properties of every solid of the part `reader` reads — its own,
258/// and those of the parts placed in it, where they are and of their own
259/// parts' materials — and of all of them together, those `scope` asks
260/// about. A part placed many times is integrated once. Stopped part way
261/// (see [`geop_core_program::watch`]), it says how far it got.
262pub fn mass_report(reader: &Reader, scope: &Scope) -> GeopResult<MassReport> {
263    let solids = placed_solids(reader)?;
264    scope.check(&solids)?;
265    let solids: Vec<_> = (solids.into_iter())
266        .filter(|solid| scope.holds(&solid.instance))
267        .collect();
268    let of = solids.len();
269    let mut stopped = None;
270    let mut bodies = Vec::new();
271    let (mut weighed, mut shapes_only) = (Vec::new(), Vec::new());
272    let mut area = (Scalar::ZERO, true);
273    let mut complete = true;
274    let mut shapes = Shapes::default();
275    for (at, placed) in solids.into_iter().enumerate() {
276        if !watch::go_on("weighing solids", at, of) {
277            stopped = Some(Stopped { checked: at, of });
278            complete = false;
279            break;
280        }
281        let material = placed.material();
282        let computed = (placed.mass_properties(&mut shapes)).and_then(|p| {
283            let known = match p {
284                Some(p) => Known::Weighed(p),
285                None => Known::Shape(placed.shape_properties(&mut shapes)?),
286            };
287            let a = placed.model.topology().solid_area(placed.solid)?;
288            Ok((MassSummary::of(&known, a)?, known, a))
289        });
290        let (properties, error) = match computed {
291            Ok((summary, known, a)) => {
292                match known {
293                    Known::Weighed(p) => weighed.push(p),
294                    Known::Shape(p) => shapes_only.push(p),
295                }
296                area = (area.0.add(a.0), area.1 && a.1);
297                (Some(summary), None)
298            }
299            Err(e) => {
300                complete = false;
301                (None, Some(e.to_string()))
302            }
303        };
304        bodies.push(BodyMass {
305            drawn: drawn(&placed),
306            name: placed.name,
307            material,
308            properties,
309            error,
310        });
311    }
312    let total = match (weighed.is_empty(), shapes_only.is_empty()) {
313        _ if !complete => None,
314        (false, true) => (MassProperties::combine(&weighed)?)
315            .map(|p| MassSummary::of(&Known::Weighed(p), area))
316            .transpose()?,
317        (true, _) => (MassProperties::combine(&shapes_only)?)
318            .map(|p| MassSummary::of(&Known::Shape(p), area))
319            .transpose()?,
320        // Some of a material, some of none: neither mean is their centre.
321        (false, false) => {
322            let all = || weighed.iter().chain(&shapes_only);
323            Some(MassSummary {
324                volume: Bounded::of(all().fold(Scalar::ZERO, |v, p| v.add(p.volume))),
325                area: Bounded::of(area.0),
326                center: None,
327                inertial: None,
328                converged: all().all(|p| p.converged) && area.1,
329            })
330        }
331    };
332    Ok(MassReport {
333        bodies,
334        total,
335        stopped,
336    })
337}
338
339/// What is drawn of `solid` where its part is placed, to light: its faces,
340/// named as the part read names them — behind the path of the part placed
341/// that it is of.
342pub(crate) fn drawn(solid: &PlacedSolid) -> Vec<PickedElement> {
343    let Some(own) = solid.model.name_of(solid.solid) else {
344        return Vec::new();
345    };
346    let reference = ElementReference::new(model(), own);
347    (Solid::drawn(&solid.reader, &reference).into_iter())
348        .map(|face| face.behind(&solid.instance))
349        .collect()
350}