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:
1 parent
dcffab09b1
commit
571122c4ba
19 files changed
+769
-9
No files matched your search
@@ -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:?}");
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const ASSEMBLE: &[&str] = &[
|
||||
"joint.rack_pinion",
|
||||
"joint.screw",
|
||||
"motion.study",
|
||||
"motion.trace",
|
||||
"explode.create",
|
||||
"contact.enable_sets",
|
||||
"contact.enable_all",
|
||||
|
||||
Reference in new issue
Block a user