Files
OneCAD/crates/ui-egui/src/dialogs_trace.rs
T
MateuszSychXpl 8fdf6a61a2 Motion analysis: plots of a study's joint values and a point's path, and the parts that collide along it
- motion.plot: joint values and a point's path sampled as the study plays (closed loops
  following), with velocities and accelerations by finite differences, per step or per
  second with a duration.
- motion.interference: the pairs of parts that pass through each other along a study, with
  the first step, the step ranges, the depth and the shared volume; occurrences, against
  or contact sets choose the pairs; bounded samples and work.
- Motion Study dialog: a Study box opens a saved study; a chart of a joint value or a
  picked point with a cursor at the step shown (click to move there); Check collisions
  with the steps shaded on the chart. ui.start motion.study takes study.
2026-10-16 17:52:38 +02:00

205 lines
9.5 KiB
Rust

//! Motion Trace: the path a picked point of a component takes while a joint is driven between
//! two values or a motion study plays. The path is computed on a scratch copy (`motion.trace`)
//! and drawn in the viewport; OK keeps it as a 3D sketch spline (`motion.trace.keep`).
use egui::{Pos2, RichText, Stroke};
use serde_json::{Value, json};
use solidcraft_engine::doc::{IDENTITY, Mat, apply_point, mat_inverse, mat_mul};
use solidcraft_engine::geom::Vec3;
use solidcraft_engine::{Sel, Session};
use super::{Hover, body_of, capital, movable, num, pt, sel_items, snap_of};
use crate::dialogs::{combo, field, row_label};
use crate::selection::SelInput;
use crate::theme::Tokens;
use crate::viewport::Proj;
#[derive(Clone, Debug)]
pub struct TraceForm {
/// Traced as a motion study plays (else as a joint is driven from `from` to `to`).
pub by_study: bool,
/// Indices into the design's joints and motion studies.
pub joint: usize,
pub index: usize,
pub from: String,
pub to: String,
pub study: usize,
/// Empty: the command's default.
pub samples: String,
pub hover: Hover,
/// The trace shown: what it was computed for, and `motion.trace`'s answer (or why none).
pub shown: Option<(String, Result<Value, String>)>,
/// The picked part's frame as shown and the inverse of its placement: the traced path (where
/// the parts are placed) is drawn moved with the part, through the picked point.
pub view: Option<(Mat, Mat)>,
}
/// The dialog on a joint (an index into the design's joints), from its value now over a turn.
pub fn form(s: &Session, joint: usize) -> TraceForm {
let (from, to) = range(s, joint, 0);
TraceForm { by_study: false, joint, index: 0, from, to, study: 0, samples: String::new(), hover: None, shown: None, view: None }
}
/// From a joint's value now over a turn (an angle) or 20 mm (a distance).
fn range(s: &Session, joint: usize, index: usize) -> (String, String) {
let Some(j) = s.doc.assembly.joints.get(joint) else { return Default::default() };
let v = j.values.get(index).copied().unwrap_or(0.0);
if j.kind.is_angle(index) { (num(v.to_degrees(), "deg"), num(v.to_degrees() + 360.0, "deg")) } else { (num(v, "mm"), num(v + 20.0, "mm")) }
}
/// The occurrence path placing the picked body: the instance clicked, also when its component
/// (or a sub-assembly holding it) is placed more than once (empty: the root design).
fn path(s: &Session, sel: &Sel) -> Option<Vec<u64>> {
let body = body_of(sel)?;
s.doc.body_frames(&s.model.state()).into_iter().find(|(name, ..)| name == body).map(|(_, _, path, _)| path)
}
/// The pick: its occurrence path, the point where the part shows, and the part's frame as shown
/// and as placed (they differ under an exploded view or a move not captured yet).
pub(super) fn placement(s: &Session, inputs: &[SelInput]) -> Option<(Vec<u64>, Vec3, Mat, Mat)> {
let pick = sel_items(inputs, 0).first()?;
let (snap, path) = (snap_of(s, pick)?, path(s, pick)?);
let world = |shown: bool| {
path.iter().try_fold(IDENTITY, |m, id| {
let o = s.doc.occurrences.iter().find(|o| o.id == *id)?;
Some(mat_mul(&m, s.pending_moves.get(id).filter(|_| shown).unwrap_or(&o.transform)))
})
};
let (shown, placed) = (world(true)?, world(false)?);
Some((path, snap.at, shown, placed))
}
/// The trace's parameters (OK's `motion.trace.keep` and the preview's `motion.trace`).
pub fn params(s: &Session, f: &TraceForm, inputs: &[SelInput]) -> Result<Value, String> {
let mut p = picked_point(s, inputs)?;
if f.by_study {
p["study"] = json!(s.doc.assembly.studies.get(f.study).ok_or("no motion study yet")?.id);
} else {
let j = s.doc.assembly.joints.get(f.joint).ok_or("no joint that moves yet")?;
p["joint"] = json!(j.id);
p["index"] = json!(f.index);
p["from"] = json!(f.from.trim());
p["to"] = json!(f.to.trim());
}
if !f.samples.trim().is_empty() {
p["samples"] = json!(f.samples.trim().parse::<f64>().map_err(|_| "samples must be a number")?);
}
Ok(p)
}
/// The picked point as `motion.trace` and `motion.plot` take it: `{occurrence, point}`.
pub fn picked_point(s: &Session, inputs: &[SelInput]) -> Result<Value, String> {
let (path, at, shown, placed) = placement(s, inputs).ok_or("select a point on a component")?;
// The point starts where the part is placed, not where it may be shown.
let at = mat_inverse(&shown).map(|m| apply_point(&placed, apply_point(&m, at))).ok_or("select a point on a component")?;
let occurrence = if path.is_empty() { json!(0) } else { json!(path) };
Ok(json!({"occurrence": occurrence, "point": pt(at)}))
}
/// Where and why the motion stopped short, ending the trace (`None`: it went all the way).
fn stop_text(v: &Value) -> Option<String> {
let at = v["stopped_at"].as_u64()?.to_string();
let name = |x: &Value| x.as_str().unwrap_or_default().to_string();
if let Some(l) = v["stopped_by_loop"].as_object() {
return Some(crate::i18n::format("trace.stopped_by_loop", &[("sample", &at), ("joint", &name(&l["joint"]))]));
}
let c = v["stopped_by_contact"].as_object()?;
Some(crate::i18n::format("trace.stopped_by_contact", &[("sample", &at), ("a", &name(&c["a"])), ("b", &name(&c["b"]))]))
}
/// The dialog's rows; true when Enter was pressed in a value.
pub fn rows(app: &mut crate::SolveApp, ui: &mut egui::Ui, f: &mut TraceForm, inputs: &[SelInput]) -> bool {
let t = Tokens::get();
let s = &app.session;
let mut enter = false;
let studies: Vec<&str> = s.doc.assembly.studies.iter().map(|m| m.name.as_str()).collect();
f.by_study &= !studies.is_empty();
if !studies.is_empty() {
row_label(ui, "Motion");
let mut by = usize::from(f.by_study);
combo(ui, "tr_by", &["Joint", "Motion Study"], &mut by);
f.by_study = by == 1;
ui.end_row();
}
if f.by_study {
row_label(ui, "Study");
combo(ui, "tr_study", &studies, &mut f.study);
ui.end_row();
} else {
let list = movable(s);
if list.is_empty() {
row_label(ui, "");
ui.label(RichText::new(crate::i18n::tr_label("no joint that moves yet")).color(t.text_dim));
ui.end_row();
return false;
}
let labels: Vec<&str> = list.iter().filter_map(|i| s.doc.assembly.joints.get(*i)).map(|j| j.name.as_str()).collect();
let mut pos = list.iter().position(|i| *i == f.joint).unwrap_or(0);
row_label(ui, "Joint");
combo(ui, "tr_joint", &labels, &mut pos);
ui.end_row();
let joint = list.get(pos).copied().unwrap_or(0);
let dofs = s.doc.assembly.joints.get(joint).map(|j| j.kind.dofs()).unwrap_or(&[]);
let mut index = if joint == f.joint { f.index.min(dofs.len().saturating_sub(1)) } else { 0 };
if dofs.len() > 1 {
row_label(ui, " Of");
let caps: Vec<String> = dofs.iter().map(|d| capital(d)).collect();
let refs: Vec<&str> = caps.iter().map(String::as_str).collect();
combo(ui, "tr_index", &refs, &mut index);
ui.end_row();
}
if joint != f.joint || index != f.index {
(f.joint, f.index) = (joint, index);
(f.from, f.to) = range(s, joint, index);
}
row_label(ui, "From");
enter |= field(ui, &mut f.from);
ui.end_row();
row_label(ui, "To");
enter |= field(ui, &mut f.to);
ui.end_row();
}
let default = match s.doc.assembly.studies.get(f.study).filter(|_| f.by_study) {
Some(m) => (u64::from(m.steps) + 1).clamp(2, 500),
None => 100,
};
row_label(ui, "Samples");
let r = ui.add(egui::TextEdit::singleline(&mut f.samples).hint_text(default.to_string()));
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.end_row();
// The preview, traced again when what it traces (or the design) changes.
let p = params(s, f, inputs);
let key = format!("{}:{p:?}", s.revision);
if f.shown.as_ref().is_none_or(|(k, _)| *k != key) {
let r = p.and_then(|p| s.scratch().execute("motion.trace", &p).map_err(|e| e.to_string()));
f.shown = Some((key, r));
f.view = placement(s, inputs).and_then(|(_, _, shown, placed)| Some((shown, mat_inverse(&placed)?)));
}
if let Some((_, r)) = &f.shown {
let (text, colour) = match r {
Ok(v) => match stop_text(v) {
Some(why) => (why, t.warning),
None => (crate::i18n::format("trace.points", &[("count", &v["samples"].to_string())]), t.text_dim),
},
Err(e) => (e.clone(), t.text_dim),
};
row_label(ui, "");
ui.add(egui::Label::new(RichText::new(text).color(colour)).wrap());
ui.end_row();
}
enter
}
/// The traced path in the viewport, with a dot where it starts.
pub fn overlay(painter: &egui::Painter, proj: &Proj, f: &TraceForm) {
let Some((_, Ok(v))) = &f.shown else { return };
let at = |p: &Value| p.as_array().and_then(|a| Some(Vec3::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?)));
let shown = |p: Vec3| f.view.map_or(p, |(shown, inverse)| apply_point(&shown, apply_point(&inverse, p)));
let pts: Vec<Pos2> = v["points"].as_array().into_iter().flatten().filter_map(at).map(shown).filter_map(|p| proj.to_screen(p)).collect();
let t = Tokens::get();
if let Some(first) = pts.first() {
painter.circle_filled(*first, 3.5, t.preview_sketch);
}
painter.add(egui::Shape::line(pts, Stroke::new(2.0, t.preview_sketch)));
}