Skip to main content

geop_ops_inspect/
interference.rs

1//! [`interference_report`]: which solids a part shows overlap — its own
2//! with each other, with those of the parts placed in it, and those
3//! between each other — by how much, and which only touch.
4
5use geop_core_editor::editing::Tone;
6use geop_core_editor::output::{Answer, Block, Bounded, Row};
7use geop_core_math::{
8    geop_error::{GeopError, GeopResult, WithContext},
9    primitives::Pose,
10    vector::{Vector, Vector3},
11};
12use geop_core_program::{
13    element::PickedElement,
14    placed::{INSTANCE_SEPARATOR, instance_path},
15    reader::Reader,
16    watch,
17};
18use geop_core_topology::{
19    Body, SolidId,
20    distance::{Feature, PlacedFace, could_touch},
21};
22use geop_ops_model::placed::{
23    locate,
24    solids::{PlacedSolid, placed_solids},
25};
26use geop_ops_model::{BodyNames, NamedModel, Namer};
27use std::collections::HashMap;
28
29use crate::{Scope, mass::drawn, scope::Stopped};
30use geop_lib_booleans::{RemeshParams, boolean};
31use geop_ops_model::BooleanOp;
32use serde::Serialize;
33
34/// An axis-aligned box: each coordinate the interval it spans.
35pub type Box3 = Vector3;
36
37/// The box of `points`: each coordinate the union of theirs.
38pub(crate) fn hull(points: &[Vector3]) -> Box3 {
39    points
40        .iter()
41        .copied()
42        .reduce(|a, b| a.union(&b))
43        .unwrap_or_else(Vector3::everything)
44}
45
46/// Whether two boxes are definitely apart along some axis.
47pub(crate) fn apart(a: &Box3, b: &Box3) -> bool {
48    (0..3).any(|k| {
49        a[k].upper().definitely_less(b[k].lower()) || b[k].upper().definitely_less(a[k].lower())
50    })
51}
52
53/// The control point `cp` of a surface of `solid`, as a point, where the
54/// solid is placed.
55fn place_control_point(solid: &PlacedSolid, cp: &Vector<4>) -> GeopResult<Box3> {
56    let p = cp.dehomogenized()?;
57    Ok(match &solid.pose {
58        Some(pose) => pose.apply(&p),
59        None => p,
60    })
61}
62
63/// The box around the faces `faces` of `solid`: around their surfaces'
64/// control points — each surface lies in their hull.
65pub fn bounding_box(solid: &PlacedSolid, faces: &[geop_core_topology::FaceId]) -> GeopResult<Box3> {
66    let model = solid.model.topology();
67    let mut points = Vec::new();
68    for &face in faces {
69        for cp in &model.get_face(face)?.surface.control_points {
70            points.push(place_control_point(solid, cp)?);
71        }
72    }
73    Ok(hull(&points))
74}
75
76/// How two solids meet.
77#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
78#[serde(rename_all = "snake_case")]
79pub enum Contact {
80    /// They share a volume.
81    Overlap,
82    /// They meet, along a face, an edge or at a point, sharing no volume.
83    Touch,
84}
85
86/// Two solids that meet, named as the part shown names them.
87#[derive(Clone, Debug, Serialize)]
88pub struct Interference {
89    pub a: String,
90    pub b: String,
91    pub contact: Contact,
92    /// The volume they share, in mm³, for an overlap.
93    pub volume: Option<Bounded>,
94    /// What is drawn of the two, to light.
95    #[serde(skip)]
96    pub drawn: Vec<PickedElement>,
97}
98
99/// A pair that could not be checked, and why.
100#[derive(Clone, Debug, Serialize)]
101pub struct Unchecked {
102    pub a: String,
103    pub b: String,
104    pub error: String,
105}
106
107/// Every pair of solids a part shows that overlap or touch; those apart
108/// are not listed.
109#[derive(Clone, Debug, Serialize)]
110pub struct InterferenceReport {
111    /// How many solids there are, and so pairs: `n (n - 1) / 2`.
112    pub solids: usize,
113    pub found: Vec<Interference>,
114    pub unchecked: Vec<Unchecked>,
115    /// What finding them took.
116    pub work: Work,
117    /// How far it got, if it was stopped before the end (see
118    /// [`geop_core_program::watch`]): the pairs not looked at may meet.
119    pub stopped: Option<Stopped>,
120}
121
122impl InterferenceReport {
123    /// As the inspect panel shows it: each pair that meets, lit while
124    /// pointed at, how — and each that could not be checked, why.
125    pub fn shown(&self) -> Answer {
126        let mut blocks = Vec::new();
127        if let Some(Stopped { checked, of }) = self.stopped {
128            blocks.push(Block::Text {
129                text: format!("Stopped after {checked} of {of} pairs: the rest may meet."),
130                tone: Tone::Error,
131            });
132        } else if self.found.is_empty() && self.unchecked.is_empty() {
133            let plural = if self.solids == 1 { "" } else { "s" };
134            blocks.push(Block::Text {
135                text: format!("No interference among {} solid{plural}.", self.solids),
136                tone: Tone::Hint,
137            });
138        }
139        let mut items: Vec<Row> = (self.found.iter())
140            .map(|f| {
141                let (how, tone) = match f.contact {
142                    Contact::Overlap => {
143                        let volume = f.volume.map_or("?".into(), |v| v.show(3));
144                        (format!("overlap {volume} mm³"), Tone::Error)
145                    }
146                    Contact::Touch => ("touch".into(), Tone::Normal),
147                };
148                Row {
149                    light: f.drawn.clone(),
150                    tone,
151                    ..Row::of(vec![format!("{} × {}: {how}", f.a, f.b)])
152                }
153            })
154            .collect();
155        items.extend(self.unchecked.iter().map(|u| Row {
156            detail: Some(u.error.clone()),
157            tone: Tone::Hint,
158            ..Row::of(vec![format!("{} × {}: could not be checked", u.a, u.b)])
159        }));
160        if !items.is_empty() {
161            blocks.push(Block::List { items });
162        }
163        Answer {
164            blocks,
165            plane: None,
166        }
167    }
168}
169
170/// The work a report took: what the broad phase compared and what the
171/// narrow phase ran — counted, not timed (see AGENTS.md "Count the work").
172#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
173pub struct Work {
174    /// Pairs of boxes compared by the sweep along `x`.
175    pub boxes_compared: usize,
176    /// Pairs whose boxes could meet: each looked at closely, or found
177    /// among those already looked at.
178    pub candidates: usize,
179    /// Booleans run: one per distinct pair of shapes and relative pose
180    /// (see [`Checked`]).
181    pub booleans: usize,
182}
183
184/// Which solids of the part `reader` reads — its own, and those of the parts
185/// placed in it, where they are — overlap, and by how much, and which only
186/// touch.
187///
188/// A pair whose boxes are definitely apart is apart; the pairs whose boxes
189/// could meet are found by sweeping the boxes along `x` ([`candidate_pairs`]),
190/// not by comparing every pair. Any other pair is intersected, as copies in
191/// a part of their own: a solid left is their overlap, measured by its
192/// volume. Nothing left, the two touch if the least distance between their
193/// faces could be zero (see [`could_touch`]), and are apart otherwise.
194///
195/// A pair is checked in the frame of its first solid, the second moved
196/// relative to it; a pair of the same two shapes at the same relative pose
197/// — every screw in its nut — is checked once ([`Checked`]).
198///
199/// Only the pairs `scope` asks about are looked at. Stopped part way (see
200/// [`geop_core_program::watch`]), it says what it found and how far it got.
201pub fn interference_report(reader: &Reader, scope: &Scope) -> GeopResult<InterferenceReport> {
202    let solids = placed_solids(reader)?;
203    scope.check(&solids)?;
204    let mut local = LocalBoxes::default();
205    let boxes = (solids.iter())
206        .map(|s| local.placed(s))
207        .collect::<GeopResult<Vec<_>>>()?;
208    let mut work = Work::default();
209    let mut pairs = candidate_pairs(&boxes, &mut work.boxes_compared);
210    pairs.retain(|&(i, j)| scope.holds_pair(&solids[i].instance, &solids[j].instance));
211    let of = pairs.len();
212    let mut stopped = None;
213    let mut checked = Checked::default();
214    let mut poses = LocalPoses::default();
215    let mut found = Vec::new();
216    let mut unchecked = Vec::new();
217    for (at, (i, j)) in pairs.into_iter().enumerate() {
218        if !watch::go_on("checking pairs of solids", at, of) {
219            stopped = Some(Stopped { checked: at, of });
220            break;
221        }
222        work.candidates += 1;
223        let (a, b) = (&solids[i], &solids[j]);
224        let relative = poses.relative(reader, a, b)?;
225        match checked.check(a, b, relative, &mut work.booleans) {
226            Ok(Some((contact, volume))) => found.push(Interference {
227                a: a.name.clone(),
228                b: b.name.clone(),
229                contact,
230                volume,
231                drawn: [drawn(a), drawn(b)].concat(),
232            }),
233            Ok(None) => {}
234            Err(e) => unchecked.push(Unchecked {
235                a: a.name.clone(),
236                b: b.name.clone(),
237                error: e.to_string(),
238            }),
239        }
240    }
241    Ok(InterferenceReport {
242        solids: solids.len(),
243        found,
244        unchecked,
245        work,
246        stopped,
247    })
248}
249
250/// A solid's shape, whatever instance shows it: the model it is a solid of
251/// — one for every instance of one build, every screw of a size — and the
252/// solid.
253#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
254pub(crate) struct Shape(*const NamedModel, SolidId);
255
256impl Shape {
257    pub(crate) fn of(solid: &PlacedSolid) -> Self {
258        Shape(std::ptr::from_ref(&*solid.model), solid.solid)
259    }
260}
261
262/// The boxes of shapes in their own part's frame, each computed once
263/// however often its part is placed.
264#[derive(Default)]
265pub(crate) struct LocalBoxes(HashMap<Shape, Box3>);
266
267impl LocalBoxes {
268    /// The box of `solid` where it is placed: the box of its shape's box's
269    /// corners moved there, which encloses the shape moved.
270    pub(crate) fn placed(&mut self, solid: &PlacedSolid) -> GeopResult<Box3> {
271        let shape = Shape::of(solid);
272        let own = match self.0.get(&shape) {
273            Some(own) => *own,
274            None => {
275                let unplaced = PlacedSolid {
276                    pose: None,
277                    name: solid.name.clone(),
278                    instance: solid.instance.clone(),
279                    reader: solid.reader.clone(),
280                    model: std::borrow::Cow::Borrowed(&*solid.model),
281                    solid: solid.solid,
282                };
283                let faces = solid.model.topology().solid_faces(solid.solid)?;
284                let own = bounding_box(&unplaced, &faces)?;
285                self.0.insert(shape, own);
286                own
287            }
288        };
289        Ok(match &solid.pose {
290            None => own,
291            Some(pose) => hull(&corners(&own).map(|c| pose.apply(&c))),
292        })
293    }
294}
295
296/// The eight corners of `b`.
297pub(crate) fn corners(b: &Box3) -> [Vector3; 8] {
298    std::array::from_fn(|k| {
299        Vector3::from_array(std::array::from_fn(|axis| match (k >> axis) & 1 {
300            0 => b[axis].lower(),
301            _ => b[axis].upper(),
302        }))
303    })
304}
305
306/// The pairs `(i, j)`, `i < j`, of `boxes` that are not definitely apart,
307/// in order: found by sweeping along `x` — the boxes sorted by where they
308/// start, each compared only with those started before it that have not
309/// definitely ended — counting each comparison in `compared`.
310pub fn candidate_pairs(boxes: &[Box3], compared: &mut usize) -> Vec<(usize, usize)> {
311    let start = |k: usize| boxes[k][0].lower();
312    let mut order: Vec<usize> = (0..boxes.len()).collect();
313    // The starts are sharp numbers, so this is a total order.
314    order.sort_by(|&a, &b| {
315        if start(a).definitely_less(start(b)) {
316            std::cmp::Ordering::Less
317        } else if start(b).definitely_less(start(a)) {
318            std::cmp::Ordering::Greater
319        } else {
320            a.cmp(&b)
321        }
322    });
323    let mut active: Vec<usize> = Vec::new();
324    let mut pairs = Vec::new();
325    for &k in &order {
326        // Every box after this one starts where it does or later: one that
327        // has ended before it starts meets none of them.
328        active.retain(|&j| !boxes[j][0].upper().definitely_less(start(k)));
329        for &j in &active {
330            *compared += 1;
331            if !apart(&boxes[j], &boxes[k]) {
332                pairs.push((j.min(k), j.max(k)));
333            }
334        }
335        active.push(k);
336    }
337    pairs.sort_unstable();
338    pairs
339}
340
341/// Where each placed part is in the part it is placed in, each found once.
342#[derive(Default)]
343pub(crate) struct LocalPoses(HashMap<String, Pose>);
344
345impl LocalPoses {
346    /// The pose of `b` relative to `a`, in the part `reader` reads: none
347    /// for two solids of one part. It is composed of the poses below the
348    /// part both are placed in — not of their poses in the part read, which
349    /// would round differently wherever that part is — so that two pairs
350    /// placed alike within copies of one part come out the same, bound for
351    /// bound.
352    pub(crate) fn relative(
353        &mut self,
354        reader: &Reader,
355        a: &PlacedSolid,
356        b: &PlacedSolid,
357    ) -> GeopResult<Option<Pose>> {
358        let (path_a, path_b) = (levels(&a.instance), levels(&b.instance));
359        let common = (path_a.iter().zip(&path_b))
360            .take_while(|(x, y)| x == y)
361            .count();
362        let below_a = self.below(reader, &path_a[common..])?;
363        let below_b = self.below(reader, &path_b[common..])?;
364        Ok(match (below_a, below_b) {
365            (None, b) => b,
366            (Some(a), None) => Some(a.inverse()),
367            (Some(a), Some(b)) => Some(a.inverse().compose(&b)),
368        })
369    }
370
371    /// The pose of the last of `paths` in the part the first is placed in:
372    /// each in the one before composed. None for no paths.
373    fn below(&mut self, reader: &Reader, paths: &[String]) -> GeopResult<Option<Pose>> {
374        let mut pose: Option<Pose> = None;
375        for path in paths {
376            let local = match self.0.get(path) {
377                Some(local) => *local,
378                None => {
379                    let local = locate(reader, path)?.local;
380                    self.0.insert(path.clone(), local);
381                    local
382                }
383            };
384            pose = Some(match pose {
385                None => local,
386                Some(outer) => outer.compose(&local),
387            });
388        }
389        Ok(pose)
390    }
391}
392
393/// The paths of the parts `instance` is placed in, outermost first, it
394/// last: `a`, `a/b`, `a/b/c`. None for the part's own.
395fn levels(instance: &str) -> Vec<String> {
396    let mut paths: Vec<String> = Vec::new();
397    for name in instance.split(INSTANCE_SEPARATOR).filter(|n| !n.is_empty()) {
398        let outer = paths.last().map_or("", String::as_str);
399        paths.push(instance_path(outer, name));
400    }
401    paths
402}
403
404/// How a pair of solids meets: whether they overlap, by what volume, or
405/// only touch — or none, apart.
406type Meeting = Option<(Contact, Option<Bounded>)>;
407
408/// The pairs checked so far, by their shapes and the pose of the second
409/// relative to the first: a pair of the same shapes at the same relative
410/// pose meets the same way, and is not checked again.
411///
412/// Poses are compared exactly, bound for bound: a pose equal only to
413/// within its width is another pose.
414#[derive(Default)]
415struct Checked(HashMap<(Shape, Shape), Checks>);
416
417/// What was found of one pair of shapes, at each relative pose checked
418/// (none: placed as they are).
419type Checks = Vec<(Option<Pose>, Result<Meeting, String>)>;
420
421impl Checked {
422    /// How `a` and `b` meet: checked, counting the boolean in `booleans`,
423    /// or as found before for the same shapes at the same relative pose.
424    fn check(
425        &mut self,
426        a: &PlacedSolid,
427        b: &PlacedSolid,
428        relative: Option<Pose>,
429        booleans: &mut usize,
430    ) -> GeopResult<Meeting> {
431        let seen = self.0.entry((Shape::of(a), Shape::of(b))).or_default();
432        if let Some((_, meeting)) = seen.iter().find(|(pose, _)| *pose == relative) {
433            return meeting.clone().map_err(GeopError::new);
434        }
435        *booleans += 1;
436        let first = PlacedSolid {
437            pose: None,
438            name: a.name.clone(),
439            instance: a.instance.clone(),
440            reader: a.reader.clone(),
441            model: std::borrow::Cow::Borrowed(&*a.model),
442            solid: a.solid,
443        };
444        let second = PlacedSolid {
445            pose: relative,
446            name: b.name.clone(),
447            instance: b.instance.clone(),
448            reader: b.reader.clone(),
449            model: std::borrow::Cow::Borrowed(&*b.model),
450            solid: b.solid,
451        };
452        let meeting = check_pair(&first, &second).map_err(|e| e.to_string());
453        seen.push((relative, meeting.clone()));
454        meeting.map_err(GeopError::new)
455    }
456}
457
458/// How `a` and `b` meet, if they do, and the volume they share.
459fn check_pair(a: &PlacedSolid, b: &PlacedSolid) -> GeopResult<Meeting> {
460    let ctx = |e: GeopError| e.with_context(format!("checking {} against {}", a.name, b.name));
461    let mut scratch = NamedModel::new();
462    let copy_a = copy_solid(&mut scratch, a, "a").with_context(&ctx)?;
463    let copy_b = copy_solid(&mut scratch, b, "b").with_context(&ctx)?;
464    let namer = Namer::new("interference", "overlap").with_context(&ctx)?;
465    let overlap = boolean(
466        &mut scratch,
467        &namer,
468        copy_a,
469        copy_b,
470        BooleanOp::Intersection,
471        RemeshParams::default(),
472    )
473    .with_context(&ctx)?;
474    if let Some(solid) = overlap {
475        let volume = scratch
476            .topology()
477            .mass_properties(solid)
478            .with_context(&ctx)?
479            .volume;
480        return Ok(Some((Contact::Overlap, Some(Bounded::of(volume)))));
481    }
482    Ok(touch(a, b)
483        .with_context(&ctx)?
484        .then_some((Contact::Touch, None)))
485}
486
487/// Whether some face of `a` could be at distance zero from some face of
488/// `b` — of those pairs whose boxes are not definitely apart.
489fn touch(a: &PlacedSolid, b: &PlacedSolid) -> GeopResult<bool> {
490    let faces = |s: &PlacedSolid| -> GeopResult<Vec<_>> {
491        (s.model.topology().solid_faces(s.solid)?.into_iter())
492            .map(|face| Ok((face, bounding_box(s, &[face])?)))
493            .collect()
494    };
495    let (fa, fb) = (faces(a)?, faces(b)?);
496    for (at, (face_a, box_a)) in fa.iter().enumerate() {
497        watch::checkpoint("looking for where two solids touch", at, fa.len())?;
498        for (face_b, box_b) in &fb {
499            if apart(box_a, box_b) {
500                continue;
501            }
502            let touching = could_touch(
503                &Feature::Face(PlacedFace::new(a.model.topology(), *face_a, a.pose)?),
504                &Feature::Face(PlacedFace::new(b.model.topology(), *face_b, b.pose)?),
505            )?;
506            if touching {
507                return Ok(true);
508            }
509        }
510    }
511    Ok(false)
512}
513
514/// A copy of `solid` in `scratch`, where it is placed, each of its entities
515/// named as it is behind `prefix`. Refuses a solid of more than one shell —
516/// one with a void — which the boolean is not asked to intersect.
517fn copy_solid(scratch: &mut NamedModel, solid: &PlacedSolid, prefix: &str) -> GeopResult<SolidId> {
518    let shells = &solid.model.topology().get_solid(solid.solid)?.shells;
519    if shells.len() != 1 {
520        return Err(GeopError::new(format!(
521            "the solid {} has {} shells — a void inside it — and interference is checked for \
522             solids of one shell only",
523            solid.name,
524            shells.len()
525        )));
526    }
527    let (spec, names) = solid.model.body_record(Body::Solid(solid.solid))?;
528    let spec = match &solid.pose {
529        Some(pose) => spec.placed(pose),
530        None => spec,
531    };
532    let names = BodyNames {
533        solid: Some(prefix.to_string()),
534        ..names.renamed(|name| format!("{prefix}.{name}"))
535    };
536    let built = scratch.build_body(spec, names)?;
537    built
538        .solid
539        .ok_or_else(|| GeopError::new(format!("the copy of {} is no solid", solid.name)))
540}