Skip to main content

geop_ops_inspect/
measure.rs

1//! [`measure`]: what picked entities measure — one alone, its position,
2//! length, area, radius or volume; two together, the least distance between
3//! them and where it is attained, the angle between them, the distance
4//! between their centres.
5
6use geop_core_editor::output::{Answer, Block, Bounded, Row};
7use geop_core_editor::view::drawn;
8use geop_core_editor::{
9    editing::{Shape as Drawn, Style, Tone, Visual},
10    selectable::Selectable,
11};
12use geop_core_geometry::shape::{Arc, Axis, Circle};
13use geop_core_math::{
14    geop_error::{GeopError, GeopResult, WithContext},
15    primitives::{CoordinateSystem, Pose},
16    scalars::Scalar,
17    vector::Vector3,
18};
19use geop_core_program::{element::PickedElement, reader::Reader};
20use geop_core_topology::distance::{Feature, PlacedFace, closest_points};
21use geop_ops_model::parameters::located;
22use geop_ops_model::selection::{Selected, Shape};
23use serde::Serialize;
24
25/// What is picked to be measured, by what is drawn of it: vertices,
26/// edges, faces, datums, and a sketch's points and curves.
27pub const PICKS: &[&str] = &[
28    drawn::VERTEX,
29    drawn::EDGE,
30    drawn::FACE,
31    drawn::DATUM,
32    drawn::SKETCH_POINT,
33    drawn::SKETCH_CURVE,
34];
35
36/// One number a measurement shows.
37#[derive(Clone, Debug, PartialEq, Serialize)]
38pub struct Measured {
39    pub label: String,
40    #[serde(flatten)]
41    pub value: Bounded,
42    /// `mm`, `mm²`, `mm³`, `kg` or `°`.
43    pub unit: &'static str,
44}
45
46/// What picked entities measure.
47#[derive(Clone, Debug, Serialize)]
48pub struct Measurement {
49    /// The entities measured, as picked.
50    pub entities: Vec<PickedElement>,
51    pub values: Vec<Measured>,
52    /// Where the least distance between two entities is attained: a point
53    /// on each.
54    pub witness: Option<[Vector3; 2]>,
55    /// The plane of one planar entity picked alone — a planar face, a datum
56    /// plane — to cut a section view with.
57    pub plane: Option<CoordinateSystem>,
58    /// Why something could not be measured.
59    pub error: Option<String>,
60}
61
62impl Measurement {
63    /// As the inspect panel shows it: what is picked — or what to pick —
64    /// why it could not be measured, and what it measures; and the plane
65    /// of a planar entity picked alone, to cut a section view with.
66    pub fn shown(&self) -> Answer {
67        let mut blocks = Vec::new();
68        if self.entities.is_empty() {
69            blocks.push(Block::Text {
70                text: "Click a vertex, edge, face or datum; a second one to measure between them."
71                    .into(),
72                tone: Tone::Hint,
73            });
74        } else {
75            blocks.push(Block::List {
76                items: (self.entities.iter())
77                    .map(|e| Row {
78                        light: vec![e.clone()],
79                        ..Row::of(vec![label(e)])
80                    })
81                    .collect(),
82            });
83        }
84        blocks.extend(self.error.iter().map(|error| Block::Text {
85            text: error.clone(),
86            tone: Tone::Error,
87        }));
88        blocks.push(Block::Table {
89            head: Vec::new(),
90            rows: (self.values.iter())
91                .map(|v| Row::labelled(&v.label, vec![format!("{} {}", v.value.show(3), v.unit)]))
92                .collect(),
93            foot: Vec::new(),
94        });
95        Answer {
96            blocks,
97            plane: self.plane.clone(),
98        }
99    }
100
101    /// What it draws in the viewport: the least distance between two
102    /// picks, as a line between the points attaining it, labelled with it.
103    pub fn visuals(&self) -> Vec<Visual> {
104        let Some([a, b]) = self.witness else {
105            return Vec::new();
106        };
107        let mut visuals = vec![Visual::new(
108            "distance",
109            Drawn::Polyline { points: vec![a, b] },
110            Style::Selected,
111        )];
112        for (key, at) in [("from", a), ("to", b)] {
113            visuals.push(Visual::new(key, Drawn::Point { at }, Style::Selected));
114        }
115        if let Some(distance) = self.values.iter().find(|m| m.label == "Distance") {
116            visuals.push(Visual::new(
117                "distance-label",
118                Drawn::Label {
119                    at: a.add(&b).prod_scalar(Scalar::from_f64(0.5)),
120                    text: format!("{:.3} mm", distance.value.value),
121                    offset: Vector3::zero(),
122                },
123                Style::Selected,
124            ));
125        }
126        visuals
127    }
128}
129
130fn measured(label: &str, value: Scalar, unit: &'static str) -> Measured {
131    Measured {
132        label: label.into(),
133        value: Bounded::of(value),
134        unit,
135    }
136}
137
138/// How the inspect panel names `picked`: `bolt: face extrude(head,end)`
139/// for one of the part placed as `bolt`.
140fn label(picked: &PickedElement) -> String {
141    match picked.instance() {
142        "" => picked.label(),
143        path => format!("{path}: {}", picked.label()),
144    }
145}
146
147/// An entity picked, in the part it is of.
148struct Picked<'a> {
149    /// The part it is of: the part read, or one placed in it.
150    part: Reader<'a>,
151    /// Where that part is placed; `None` for the part read itself.
152    pose: Option<Pose>,
153    element: Selected,
154}
155
156impl<'a> Picked<'a> {
157    /// What `picked`, a pick of what is drawn of the part `reader` reads,
158    /// is in the part it is of.
159    fn of(reader: &Reader<'a>, picked: &PickedElement) -> GeopResult<Self> {
160        let (part, pose) = match picked.instance() {
161            "" => (reader.clone(), None),
162            path => (reader.placed_part(path)?, Some(located(reader, path)?)),
163        };
164        let inner = picked.local();
165        let element = (Selected::from_picked(&inner))
166            .or_else(|| Selected::select(&part, &inner))
167            .ok_or_else(|| {
168                GeopError::new(format!(
169                    "{} cannot be measured: pick a vertex, an edge, a face, a datum, or a sketch's point or curve",
170                    label(picked)
171                ))
172            })?;
173        Ok(Self {
174            part,
175            pose,
176            element,
177        })
178    }
179
180    /// What it is, where its part is placed.
181    fn shape(&self) -> GeopResult<Shape> {
182        let shape = self.element.shape(&self.part)?;
183        match &self.pose {
184            Some(pose) => placed(shape, pose),
185            None => Ok(shape),
186        }
187    }
188}
189
190/// `shape` once its part is moved by `pose`: a part placed in another is
191/// measured as it is placed.
192fn placed(shape: Shape, pose: &Pose) -> GeopResult<Shape> {
193    let motion = pose.motion();
194    let axis = |a: Axis| Axis {
195        point: motion.apply(&a.point),
196        direction: motion.rotate(&a.direction),
197    };
198    let frame = |f: Option<CoordinateSystem>| f.map(|f| motion.apply_frame(&f)).transpose();
199    Ok(Shape {
200        point: shape.point.map(|p| motion.apply(&p)),
201        line: shape.line.map(axis),
202        plane: frame(shape.plane)?,
203        arc: shape.arc.map(|arc| Arc {
204            circle: Circle {
205                center: motion.apply(&arc.circle.center),
206                normal: motion.rotate(&arc.circle.normal),
207                radius: arc.circle.radius,
208            },
209            start: motion.apply(&arc.start),
210            end: motion.apply(&arc.end),
211        }),
212        round: shape.round.map(axis),
213        curve: shape.curve.map(|c| c.transform(&motion)),
214        frame: frame(shape.frame)?,
215    })
216}
217
218/// The direction `shape` has: a line's, or a plane's normal — `true` for a
219/// normal.
220fn direction(shape: &Shape) -> Option<(Vector3, bool)> {
221    match (&shape.line, &shape.plane) {
222        (Some(line), _) => Some((line.direction, false)),
223        (None, Some(plane)) => Some((*plane.w(), true)),
224        _ => None,
225    }
226}
227
228/// What the entities `picked` of what is drawn of the part `reader` reads
229/// measure: nothing for none, one alone, or two together. More than two
230/// are refused.
231pub fn measure(reader: &Reader, picked: &[PickedElement]) -> Measurement {
232    let mut out = Measurement {
233        entities: picked.to_vec(),
234        values: Vec::new(),
235        witness: None,
236        plane: None,
237        error: None,
238    };
239    let result = match picked {
240        [] => Ok(()),
241        [one] => alone(reader, one, &mut out),
242        [a, b] => together(reader, a, b, &mut out),
243        _ => Err(GeopError::new(format!(
244            "{} entities are picked: a measurement takes one or two",
245            picked.len()
246        ))),
247    };
248    if let Err(e) = result {
249        out.error = Some(e.to_string());
250    }
251    out
252}
253
254/// One entity alone: a point's coordinates, a curve's length — an edge's
255/// or a sketch's, and its radius, if it is circular — a face's area — and
256/// radius, if it is a cylinder.
257fn alone(reader: &Reader, picked: &PickedElement, out: &mut Measurement) -> GeopResult<()> {
258    let ctx = |e: GeopError| e.with_context(format!("measuring {}", label(picked)));
259    let entity = Picked::of(reader, picked).with_context(&ctx)?;
260    let shape = entity.shape().with_context(&ctx)?;
261    out.plane = shape.plane.clone();
262    if let Some(curve) = &shape.curve {
263        out.values.push(measured(
264            "Length",
265            curve.length().with_context(&ctx)?.0,
266            "mm",
267        ));
268    }
269    if let Selected::Face(face) = &entity.element {
270        let face = entity.part.retrieve_element(face).with_context(&ctx)?;
271        let topology = face.model.topology();
272        let (area, _) = topology.face_area(face.id).with_context(&ctx)?;
273        out.values.push(measured("Area", area, "mm²"));
274        let surface = &topology.get_face(face.id)?.surface;
275        if let Some(radius) = (surface.as_round_wall().with_context(&ctx)?)
276            .filter(|w| w.taper.could_be_equal(Scalar::ZERO))
277            .map(|w| w.radius)
278        {
279            out.values.push(measured("Radius", radius, "mm"));
280            out.values
281                .push(measured("Diameter", radius.mul(Scalar::TWO), "mm"));
282        }
283    }
284    if let Some(arc) = &shape.arc {
285        out.values.push(measured("Radius", arc.circle.radius, "mm"));
286        out.values.push(measured(
287            "Diameter",
288            arc.circle.radius.mul(Scalar::TWO),
289            "mm",
290        ));
291    }
292    if let Some(p) = shape.point {
293        for (k, axis) in ["X", "Y", "Z"].into_iter().enumerate() {
294            out.values.push(measured(axis, p[k], "mm"));
295        }
296    }
297    Ok(())
298}
299
300/// Two entities together: the least distance between them, if both are
301/// points, edges or faces; the angle between them, if both have a
302/// direction; the distance between their centres, if both are circular.
303fn together(
304    reader: &Reader,
305    a: &PickedElement,
306    b: &PickedElement,
307    out: &mut Measurement,
308) -> GeopResult<()> {
309    let ctx = |e: GeopError| e.with_context(format!("measuring {} and {}", label(a), label(b)));
310    let (pa, pb) = (
311        Picked::of(reader, a).with_context(&ctx)?,
312        Picked::of(reader, b).with_context(&ctx)?,
313    );
314    let (sa, sb) = (
315        pa.shape().with_context(&ctx)?,
316        pb.shape().with_context(&ctx)?,
317    );
318    let (fa, fb) = (feature(&pa, &sa), feature(&pb, &sb));
319    match (fa, fb) {
320        (Ok(fa), Ok(fb)) => {
321            let closest = closest_points(&fa, &fb).with_context(&ctx)?;
322            out.values
323                .push(measured("Distance", closest.distance, "mm"));
324            let delta = closest.b.sub(&closest.a);
325            for (k, axis) in ["dX", "dY", "dZ"].into_iter().enumerate() {
326                out.values.push(measured(axis, delta[k].abs(), "mm"));
327            }
328            out.witness = Some([closest.a, closest.b]);
329        }
330        (Err(e), _) | (_, Err(e)) if direction(&sa).is_none() || direction(&sb).is_none() => {
331            return Err(ctx(e));
332        }
333        _ => {}
334    }
335    if let (Some(da), Some(db)) = (direction(&sa), direction(&sb)) {
336        out.values.push(measured("Angle", angle(&da, &db)?, "°"));
337    }
338    if let (Some(ca), Some(cb)) = (&sa.arc, &sb.arc) {
339        let between = cb.circle.center.sub(&ca.circle.center).norm();
340        out.values.push(measured("Centre distance", between, "mm"));
341    }
342    Ok(())
343}
344
345/// What a distance can be measured to: a vertex or another point, an edge
346/// or a sketch's curve, a face — each where it is placed; `shape` is what
347/// `entity` is there.
348fn feature<'m>(entity: &Picked<'m>, shape: &Shape) -> GeopResult<Feature<'m>> {
349    let ctx = |e: GeopError| {
350        e.with_context(format!(
351            "measuring a distance to {}",
352            entity.element.picked().label()
353        ))
354    };
355    if let Selected::Face(face) = &entity.element {
356        let face = entity.part.retrieve_element(face).with_context(&ctx)?;
357        return PlacedFace::new(face.model.topology(), face.id, entity.pose)
358            .map(Feature::Face)
359            .with_context(&ctx);
360    }
361    match (&shape.curve, shape.point) {
362        (Some(curve), _) => Ok(Feature::Curve(curve.clone())),
363        (None, Some(point)) => Ok(Feature::Point(point)),
364        (None, None) => Err(ctx(GeopError::new(
365            "a distance is measured between vertices, points, curves and faces",
366        ))),
367    }
368}
369
370/// The acute angle between two lines, two planes or a line and a plane, in
371/// degrees: between two directions, or between two normals, the angle
372/// between them folded to at most 90°; between a line and a plane, 90°
373/// less the angle between the line and the normal.
374fn angle((a, a_normal): &(Vector3, bool), (b, b_normal): &(Vector3, bool)) -> GeopResult<Scalar> {
375    let (a, b) = (a.normalize()?, b.normalize()?);
376    let cos = a.prod_dot(&b).abs();
377    let sin = a.prod_cross(&b).norm();
378    // In degrees: the interval atan2 encloses the angle, and the factor
379    // 180 / pi is an enclosure too.
380    let between = sin.atan2(cos).mul(Scalar::from_f64(180.0).div(Scalar::PI)?);
381    Ok(if a_normal == b_normal {
382        between
383    } else {
384        Scalar::from_f64(90.0).sub(between)
385    })
386}