Skip to main content

geop_ops_assembly/
explode.rs

1//! [`Explode`]: one move of an exploded view — placed parts moved off
2//! where they are assembled, along a direction and turned about it, a
3//! line trailing each from where it was. A sequence of moves is a sequence
4//! of steps: each moves its parts on from where the ones before left them,
5//! and seeking the timeline through them takes the assembly apart move by
6//! move. What a move is to the scene and to a drawing is
7//! [`geop_ops_model::placed::explode`]'s: the state and the mates stay as
8//! assembled.
9//!
10//! In its dialog, a click on a placed part takes it in or out of the move,
11//! and dragging one of the parts moved — or the distance's handle — slides
12//! them along the direction.
13
14use geop_core_editor::{
15    edit_form::{Edit, EditForm, FormState},
16    editable::{Context, OperationGroup, OperationInfo, OperationTier},
17    editing::{CanvasEvent, Choice, ListItem, Number, Shape, Style, Tone, Track, Unit, Visual},
18    part_operation,
19    part_operation::PartOperation,
20    view::{Annotations, ViewAnnotation, annotations},
21};
22use geop_core_math::{
23    geop_error::{GeopError, GeopResult, WithContext},
24    primitives::Pose,
25    scalars::Scalar,
26    vector::Vector3,
27    with_context,
28};
29use geop_core_program::{element::ElementReference, part::StepContext, reader::Reader};
30use geop_ops_model::{
31    mate_entity::MateEntity,
32    placed::{
33        drawn,
34        explode::{Explosion, exploded, explosion, explosions},
35        locate, placed_parts,
36    },
37};
38use serde::{Deserialize, Serialize};
39
40/// Moves placed parts of the part off where they are assembled — see the
41/// module docs.
42#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
43pub struct Explode;
44
45/// Which way a move goes: along one of the part's axes, or along the `z`
46/// axis of the frame something picked gives — a face's normal, an edge's
47/// direction, a datum's axis (see [`MateEntity::frame`]), where its part
48/// is assembled.
49#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum Direction {
52    X,
53    Y,
54    #[default]
55    Z,
56    Along(MateEntity),
57}
58
59#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
60pub struct ExplodeArgs {
61    /// The placed parts moved, by their paths: `bolt`, or `asm/bolt` for
62    /// one placed in a placed part.
63    #[serde(default)]
64    pub parts: Vec<String>,
65    #[serde(default)]
66    pub direction: Direction,
67    /// Against the direction.
68    #[serde(default)]
69    pub reversed: bool,
70    /// How far along it.
71    #[serde(default)]
72    pub distance: f64,
73    /// How far turned about it, through the middle of the parts moved, in
74    /// degrees.
75    #[serde(default)]
76    pub angle: f64,
77    /// Whether a line trails each part from where it was.
78    #[serde(default = "trailed")]
79    pub trails: bool,
80}
81
82fn trailed() -> bool {
83    true
84}
85
86impl Default for ExplodeArgs {
87    fn default() -> Self {
88        ExplodeArgs {
89            parts: Vec::new(),
90            direction: Direction::Z,
91            reversed: false,
92            distance: 0.0,
93            angle: 0.0,
94            trails: true,
95        }
96    }
97}
98
99/// The unit direction a move of `args` goes in, in the part `reader` reads.
100fn direction(reader: &Reader, args: &ExplodeArgs) -> GeopResult<Vector3> {
101    let axis = |k: usize| Vector3::axis(k);
102    let along = match &args.direction {
103        Direction::X => axis(0),
104        Direction::Y => axis(1),
105        Direction::Z => axis(2),
106        Direction::Along(entity) => *entity.frame_in_world(reader)?.w(),
107    };
108    Ok(if args.reversed { along.neg() } else { along })
109}
110
111/// The middle of the box of the parts `parts` — of their own boxes'
112/// middles — where the moves `before` leave them in the part `reader`
113/// reads: what a move turns them about. A free choice, so sharp.
114fn middle(reader: &Reader, parts: &[String], before: &[&Explosion]) -> GeopResult<Vector3> {
115    let mut bounds: Option<[Scalar; 3]> = None;
116    for part in parts {
117        let at = centre_of(reader, part, before)?;
118        let at = at.to_array();
119        bounds = Some(match bounds {
120            Some(b) => std::array::from_fn(|k| b[k].union(at[k])),
121            None => at,
122        });
123    }
124    let bounds = bounds.ok_or_else(|| GeopError::new("no part to move"))?;
125    Ok(Vector3::from_array(bounds.map(|b| b.midpoint())))
126}
127
128/// The middle of the box of the part placed at `path`, where the moves
129/// `before` leave it in the part `reader` reads.
130fn centre_of(reader: &Reader, path: &str, before: &[&Explosion]) -> GeopResult<Vector3> {
131    let located = locate(reader, path)?;
132    let centre = drawn(reader, path)?.view.extent.center;
133    Ok(exploded(before, path, &located.world).apply(&centre))
134}
135
136/// Checks `args` name parts to move, each placed in the part `reader`
137/// reads.
138fn check(reader: &Reader, args: &ExplodeArgs) -> GeopResult<()> {
139    if args.parts.is_empty() {
140        return Err(GeopError::new(
141            "click the placed parts to move: none is chosen yet",
142        ));
143    }
144    for part in &args.parts {
145        if locate(reader, part).is_err() {
146            return Err(GeopError::new(format!(
147                "{part:?} is no part placed so far: move one an earlier step placed"
148            )));
149        }
150    }
151    Ok(())
152}
153
154/// The move `args` makes in the part `reader` reads, after the moves the
155/// steps before made: the generator of its [`explosion`].
156fn plan(reader: &Reader, args: &ExplodeArgs) -> GeopResult<Explosion> {
157    check(reader, args)?;
158    let along = direction(reader, args)?;
159    let offset = along.prod_scalar(Scalar::from_f64(args.distance));
160    let slide = Pose::identity().with_position(offset);
161    // Not turned at all, nothing is turned: an exact turn of nothing
162    // composed would round every pose a hair outward.
163    let motion = match args.angle == 0.0 {
164        true => slide,
165        false => {
166            let before = explosions(reader);
167            let middle = middle(reader, &args.parts, &before)?;
168            let radians = Scalar::from_f64(args.angle).degrees_to_radians()?;
169            slide.compose(&Pose::rotation_about(&middle, &along, radians)?)
170        }
171    };
172    Ok(Explosion {
173        parts: args.parts.clone(),
174        motion,
175    })
176}
177
178/// The lines trailing each part the step `step` moves, as `args` asks:
179/// from the middle of its box before the move to after it.
180fn trails(reader: &Reader, (step, args): &(String, ExplodeArgs)) -> GeopResult<Annotations> {
181    if !args.trails {
182        return Ok(Annotations::default());
183    }
184    let moved = reader.retrieve_element(&ElementReference::whole(explosion(step)))?;
185    let before: Vec<&Explosion> = (explosions(reader).into_iter())
186        .filter(|e| !std::ptr::eq(*e, moved))
187        .collect();
188    let lines = (moved.parts.iter())
189        .map(|part| {
190            let from = centre_of(reader, part, &before)?;
191            Ok(ViewAnnotation {
192                name: format!("explode({step},{part})"),
193                label: format!("trail of {part}"),
194                polyline: vec![from, moved.motion.apply(&from)],
195            })
196        })
197        .collect::<GeopResult<Vec<_>>>()?;
198    Ok(Annotations(lines))
199}
200
201/// What the dialog keeps while a move is edited: the part the pointer is
202/// over, and the distance when a drag of the parts began.
203#[derive(Clone, Debug, Default)]
204pub struct ExplodeSession {
205    hover: Option<String>,
206    grabbed: Option<f64>,
207    /// Whether the direction is to be picked, before anything is.
208    along: bool,
209}
210
211/// The key of the visual of the part moved `part`, to drag.
212fn moved_key(part: &str) -> String {
213    format!("move:{part}")
214}
215
216/// The placed part of the part `context` edits the pointer `pointer` is
217/// over, the nearest of them.
218fn part_at(context: &Context, pointer: &geop_core_editor::editing::Pointer) -> Option<String> {
219    let scene = context.scene?;
220    let reader = context.built.as_ref().unwrap_or(&context.before);
221    (placed_parts(reader).ok()?.into_iter())
222        .filter_map(|placed| Some((scene.hit_instance(&placed.name, pointer)?, placed.name)))
223        .min_by(|a, b| a.0.to_f64().total_cmp(&b.0.to_f64()))
224        .map(|(_, name)| name)
225}
226
227part_operation!(Explode(ExplodeArgs));
228
229impl PartOperation for Explode {
230    const KIND: &'static str = "explode";
231    const INFO: OperationInfo = OperationInfo::new(
232        "Explode",
233        "Move placed parts off where they are assembled, along a direction and turned about it — one move of an exploded view, a line trailing each part.",
234        OperationGroup::Assembly,
235        OperationTier::Small,
236    )
237    .icon(crate::icons::EXPLODE);
238    type Args = ExplodeArgs;
239    type Session = ExplodeSession;
240
241    /// Nothing moved yet: straight up, trailed.
242    fn new_args(_: &Context) -> ExplodeArgs {
243        ExplodeArgs::default()
244    }
245
246    /// The move, and the lines trailing its parts.
247    fn run(ctx: &mut StepContext, args: &ExplodeArgs) -> GeopResult<()> {
248        let step = ctx.step().to_string();
249        let context = with_context!("explode({step}, parts={:?})", args.parts);
250        ctx.produce(&explosion(&step), args, plan)
251            .with_context(context)?;
252        let trailed = (step.clone(), args.clone());
253        ctx.produce(&annotations(&step), &trailed, trails)
254            .with_context(context)
255    }
256
257    /// The parts moved, which way, how far and how far turned, and whether
258    /// they trail lines; the parts lit, to drag.
259    fn form<'a>(
260        context: &'a Context<'a>,
261        args: &ExplodeArgs,
262        editing: &FormState<ExplodeSession>,
263    ) -> EditForm<'a, ExplodeArgs, ExplodeSession> {
264        let mut f = EditForm::<ExplodeArgs, ExplodeSession>::default();
265        let before = &context.before;
266        f.form.dialog.text(
267            "hint",
268            "Click the placed parts to move, then drag one of them, or the arrow, along the direction.",
269            Tone::Hint,
270        );
271        let items = (args.parts.iter())
272            .map(|part| {
273                let key = format!("part:{part}");
274                let moved = part.clone();
275                f.on(key.clone(), move |Edit { args, .. }, value| {
276                    if matches!(value, geop_core_editor::editing::Value::Remove) {
277                        args.parts.retain(|p| *p != moved);
278                    }
279                });
280                let mut item = ListItem::new(key, part.clone());
281                item.removable = true;
282                item
283            })
284            .collect();
285        f.form
286            .dialog
287            .list("parts", items, "None yet: click a placed part.");
288
289        let way = match &args.direction {
290            _ if editing.session.along => "along",
291            Direction::X => "x",
292            Direction::Y => "y",
293            Direction::Z => "z",
294            Direction::Along(_) => "along",
295        };
296        let choices = vec![
297            Choice::new("z", "up (z)"),
298            Choice::new("x", "along x"),
299            Choice::new("y", "along y"),
300            Choice::new("along", "along what is picked"),
301        ];
302        f.select(
303            "direction",
304            "direction",
305            way,
306            choices,
307            false,
308            |Edit { args, session, .. }, way| {
309                session.along = way == "along";
310                args.direction = match way {
311                    "x" => Direction::X,
312                    "y" => Direction::Y,
313                    "z" => Direction::Z,
314                    // Kept as it is until something is picked.
315                    _ => args.direction.clone(),
316                };
317            },
318        );
319        if way == "along" {
320            let along = match &args.direction {
321                Direction::Along(entity) => Some(entity),
322                _ => None,
323            };
324            f.pick_one("along", "along", before, along, |e, entity| {
325                e.args.direction = Direction::Along(entity);
326                e.session.along = false;
327            });
328        }
329        f.checkbox(
330            "reversed",
331            "the other way",
332            args.reversed,
333            |Edit { args, .. }, reversed| args.reversed = reversed,
334        );
335        // The handle, at the parts' middle where this move leaves it,
336        // sliding along the direction.
337        let shown = context.built.as_ref().unwrap_or(before);
338        let handle = (|| -> GeopResult<Track> {
339            let along = direction(shown, args)?;
340            let all = explosions(shown);
341            let at = middle(shown, &args.parts, &all)?;
342            Ok(Track {
343                at,
344                direction: along,
345            })
346        })()
347        .ok();
348        f.number(
349            "distance",
350            Number::new("distance", args.distance, Unit::Length).handle(handle),
351            |Edit { args, .. }, distance| args.distance = distance,
352        );
353        f.number(
354            "angle",
355            Number::new("turned", args.angle, Unit::Angle),
356            |Edit { args, .. }, angle| args.angle = angle,
357        );
358        f.checkbox(
359            "trails",
360            "trail lines",
361            args.trails,
362            |Edit { args, .. }, trails| args.trails = trails,
363        );
364        for part in &args.parts {
365            let shape = Shape::Instance { name: part.clone() };
366            f.form
367                .visuals
368                .push(Visual::new(moved_key(part), shape, Style::Selected).draggable());
369        }
370        if let Some(hover) = &editing.session.hover
371            && !args.parts.contains(hover)
372        {
373            let shape = Shape::Instance {
374                name: hover.clone(),
375            };
376            f.form
377                .visuals
378                .push(Visual::new("hover", shape, Style::Hover));
379        }
380        f
381    }
382
383    /// A hover lights the part a click takes in or out of the move; a drag
384    /// of a part moved slides the parts along the direction, from the
385    /// distance it began at.
386    fn canvas(context: &Context, edit: Edit<'_, ExplodeArgs, ExplodeSession>, event: &CanvasEvent) {
387        let Edit { args, session, .. } = edit;
388        match event {
389            CanvasEvent::Hover { pointer, .. } => session.hover = part_at(context, pointer),
390            CanvasEvent::Leave | CanvasEvent::Cancel => session.hover = None,
391            CanvasEvent::Click { pointer, .. } => {
392                if let Some(part) = part_at(context, pointer) {
393                    match args.parts.iter().position(|p| *p == part) {
394                        Some(k) => {
395                            args.parts.remove(k);
396                        }
397                        None => args.parts.push(part),
398                    }
399                }
400            }
401            CanvasEvent::Move {
402                key,
403                from,
404                to,
405                done,
406                ..
407            } if key.starts_with("move:") => {
408                let shown = context.built.as_ref().unwrap_or(&context.before);
409                let Ok(along) = direction(shown, args) else {
410                    return;
411                };
412                let start = *session.grabbed.get_or_insert(args.distance);
413                // Where the pointer is: a free choice, made by whoever moved
414                // it.
415                let slid = to.sub(from).prod_dot(&along).to_f64();
416                args.distance = start + slid;
417                if *done {
418                    session.grabbed = None;
419                }
420            }
421            _ => {}
422        }
423    }
424}