Motion Trace: a point's path as a joint is driven or a study plays, kept as a 3D spline (#1529)

Thanks @SychPL.
This commit is contained in:
Mateusz Sych authored and GitHub committed 2026-10-16 01:54:34 -04:00
1 parent dcffab09b1
commit 571122c4ba
19 files changed
+769 -9

No files matched your search

+25 -9
View File
@@ -21,6 +21,10 @@ use crate::theme::Tokens;
use crate::viewport::Proj;
#[path = "dialogs_trace.rs"]
mod trace;
pub use trace::TraceForm;
/// Joint types in the order the Type box lists them (command names).
pub const TYPES: [&str; 7] = ["rigid", "revolute", "slider", "cylindrical", "pin_slot", "planar", "ball"];
const TYPE_LABELS: [&str; 7] = ["Rigid", "Revolute", "Slider", "Cylindrical", "Pin-slot", "Planar", "Ball"];
@@ -73,6 +77,8 @@ pub enum Asm {
overlaps: Vec<Mesh>,
of: Vec<Sel>,
},
/// Motion Trace: a picked point's path, previewed; OK keeps it as a 3D sketch spline.
Trace(Box<TraceForm>),
}
/// The snap under the cursor, remembered for the selection it was found for.
@@ -146,6 +152,7 @@ impl Asm {
_ => "SCREW",
},
Asm::Interference { .. } => "INTERFERENCE",
Asm::Trace(_) => "MOTION TRACE",
}
}
@@ -169,16 +176,10 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
"joint.origin" => (Asm::JointOrigin { name: String::new(), hover: None }, vec![SelInput::new("Snap", SNAPS, false)]),
"joint.rigid_group" => (Asm::RigidGroup, vec![SelInput::new("Components", BODIES, true)]),
"joint.drive" => {
// The joint picked in the browser, else the first that moves.
let list = movable(s);
let picked = app
.tree
.picked_joints
.iter()
.find_map(|id| list.iter().copied().find(|i| s.doc.assembly.joints.get(*i).is_some_and(|j| j.id == *id)));
let joint = picked.or(list.first().copied()).unwrap_or(0);
let joint = picked_joint(app);
(Asm::Drive { joint, values: current_values(s, joint), focus: true }, vec![])
}
"motion.trace" => (Asm::Trace(Box::new(trace::form(s, picked_joint(app)))), vec![SelInput::new("Point", SNAPS, false)]),
"joint.motion_link" => {
let b = usize::from(movable(s).len() > 1);
(Asm::MotionLink { a: 0, b, ia: 0, ib: 0, ratio: "1".into(), offset: "0".into() }, vec![])
@@ -234,6 +235,15 @@ pub fn preset(s: &Session, k: &mut Asm, p: &Value) -> Result<(), String> {
Ok(())
}
/// The joint picked in the browser, else the first that moves (an index into the design's joints).
fn picked_joint(app: &SolveApp) -> usize {
let s = &app.session;
let list = movable(s);
let picked =
app.tree.picked_joints.iter().find_map(|id| list.iter().copied().find(|i| s.doc.assembly.joints.get(*i).is_some_and(|j| j.id == *id)));
picked.or(list.first().copied()).unwrap_or(0)
}
/// Indices (into the design's joints) of joints that move (not rigid).
fn movable(s: &Session) -> Vec<usize> {
s.doc.assembly.joints.iter().enumerate().filter(|(_, j)| j.kind != JointKind::Rigid).map(|(i, _)| i).collect()
@@ -691,6 +701,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
interference_rows(app, ui, r);
}
}
Asm::Trace(f) => enter |= trace::rows(app, ui, f, inputs),
}
enter
}
@@ -905,6 +916,7 @@ pub fn commands(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Result<Vec<(Str
Asm::Coupling(f) => return crate::dialogs_coupling::commands(app, f, inputs),
// The check runs from its Compute button; OK only closes.
Asm::Interference { .. } => Vec::new(),
Asm::Trace(f) => cmd("motion.trace.keep", trace::params(s, f, inputs)?),
})
}
@@ -1052,11 +1064,15 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
}
return;
}
// A joint names its two picks; a joint origin has one.
if let Asm::Trace(f) = k {
trace::overlay(painter, proj, f);
}
// A joint names its two picks; a joint origin and a trace have one.
let two = matches!(k, Asm::Joint(_));
let hover = match k {
Asm::Joint(f) => Some(&mut f.hover),
Asm::JointOrigin { hover, .. } => Some(hover),
Asm::Trace(f) => Some(&mut f.hover),
_ => None,
};
let Some(hover) = hover else { return };
@@ -551,3 +551,53 @@ fn drive_preset_refuses_a_rigid_joint() {
let e = preset(&app.session, &mut k, &json!({"joint": "Glue"})).unwrap_err();
assert!(e.contains("rigid"), "{e}");
}
/// Motion Trace on the four-bar: a pick on the coupler and the first joint (the crank) driven a
/// turn give the trace's parameters, and OK keeps the path as a 3D sketch spline. A pick on the
/// coupler where an exploded view shows it traces from where the coupler is placed.
#[test]
fn motion_trace_keeps_the_picked_point_path() {
let mut app = four_bar();
let coupler = occ(&app, "Coupler");
let top = |app: &SolveApp| {
let b = occurrence_bounds(&app.session, &[coupler]);
Vec3::new((b.min.x + b.max.x) / 2.0, (b.min.y + b.max.y) / 2.0, b.max.z)
};
let placed = top(&app);
app.start("motion.trace");
let mut d = app.dialog.clone().unwrap();
assert!(apply_commands(&app, &d).is_err(), "no point yet");
d.pick(&app.session, face_sel(&app.session, placed).unwrap());
let c = apply_commands(&app, &d).unwrap();
let (id, p) = &c[0];
assert_eq!(id, "motion.trace.keep");
let crank = app.session.doc.assembly.joints.iter().find(|j| j.name == "Crank").unwrap().id;
assert_eq!((&p["occurrence"], &p["joint"], &p["from"], &p["to"]), (&json!([coupler]), &json!(crank), &json!("0 deg"), &json!("360 deg")), "{p}");
let at = |p: &Value| Vec3::new(p["point"][0].as_f64().unwrap(), p["point"][1].as_f64().unwrap(), p["point"][2].as_f64().unwrap());
assert!(at(p).dist(placed) < 1e-6, "{p}");
let preview = app.session.scratch().execute("motion.trace", p).unwrap();
assert!(preview["samples"] == 100 && preview["stopped_at"].is_null(), "{preview}");
crate::dialogs::run_dialog(&mut app, &d).unwrap();
let sketch = app.session.doc.find_feature("Motion Trace1").unwrap().id;
let wire = app.session.doc.sketch(sketch).unwrap().wires[0].clone();
assert_eq!(wire.fit.len(), 100);
assert_eq!(wire.fit.first(), wire.fit.last(), "a full turn closes the spline");
// Exploded: the coupler shows away from its placement; the pick maps back to it.
app.run("explode.create", json!({"name": "Apart", "scale": 1})).unwrap();
app.run("explode.show", json!({"view": "Apart"})).unwrap();
let shown = top(&app);
assert!(shown.dist(placed) > 1.0, "the exploded view moves the coupler");
app.start("motion.trace");
let mut d = app.dialog.clone().unwrap();
d.pick(&app.session, face_sel(&app.session, shown).unwrap());
let p = apply_commands(&app, &d).unwrap()[0].1.clone();
assert!(at(&p).dist(placed) < 1e-6, "{p}");
// The preview is drawn moved with the coupler: it starts at the point picked where it shows.
let start = app.session.scratch().execute("motion.trace", &p).unwrap()["points"][0].clone();
let (_, _, seen, put) = trace::placement(&app.session, &d.inputs).unwrap();
let drawn = {
use solidcraft_engine::doc::{apply_point, mat_inverse};
apply_point(&seen, apply_point(&mat_inverse(&put).unwrap(), at(&json!({"point": start}))))
};
assert!(drawn.dist(shown) < 1e-6, "{drawn:?} wants {shown:?}");
}
+199
View File
@@ -0,0 +1,199 @@
//! 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 (path, at, shown, placed) = placement(s, inputs).ok_or("select a point on a component")?;
// The trace 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) };
let mut p = json!({"occurrence": occurrence, "point": pt(at)});
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)
}
/// 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)));
}
+1
View File
@@ -20,6 +20,7 @@ const ASSEMBLE: &[&str] = &[
"joint.rack_pinion",
"joint.screw",
"motion.study",
"motion.trace",
"explode.create",
"contact.enable_sets",
"contact.enable_all",