diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 38437508..7e49668c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -206,6 +206,8 @@ pub enum Kind { Plastic(crate::dialogs_plastic::Pl), /// Appearance (`dialogs_appearance`). Appearance(crate::dialogs_appearance::Ap), + /// Contact sets, motion studies, exploded views, configurations (`dialogs_motion`). + Motion(crate::dialogs_motion::Mo), } /// The rest of the Hole dialog: placement, extents, tap type and drill point. @@ -405,7 +407,8 @@ impl Dialog { let (kind, inputs) = crate::dialogs_assembly::start(app, id) .or_else(|| crate::dialogs_sheet::start(app, id)) .or_else(|| crate::dialogs_plastic::start(app, id)) - .or_else(|| crate::dialogs_appearance::start(app, id))?; + .or_else(|| crate::dialogs_appearance::start(app, id)) + .or_else(|| crate::dialogs_motion::start(app, id))?; Dialog::new(kind, inputs) } }; @@ -756,6 +759,7 @@ fn title(k: &Kind) -> &'static str { Kind::Sheet(k) => k.title(), Kind::Plastic(k) => k.title(), Kind::Appearance(_) => "APPEARANCE", + Kind::Motion(k) => k.title(), } } @@ -1037,9 +1041,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let anchor = egui::pos2(vp.right(), vp.top() + crate::viewport::VIEW_CUBE_CLEARANCE); let mut keep = true; let mut ok = false; + let mut applied = false; let mut cancel = false; let mut enter = false; - let wide = matches!(d.kind, Kind::Params { .. }); + let wide = matches!(d.kind, Kind::Params { .. }) || matches!(&d.kind, Kind::Motion(k) if k.wide()); let heading = if d.editing.is_some() { format!("EDIT {}", title(&d.kind)) } else { title(&d.kind).to_string() }; let frame = egui::Frame::window(&ctx.global_style()) .fill(t.dialog_bg) @@ -1404,6 +1409,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { Kind::Sheet(k) => enter |= crate::dialogs_sheet::rows(app, ui, k, &mut d.inputs), Kind::Plastic(k) => enter |= crate::dialogs_plastic::rows(app, ui, k, &d.inputs), Kind::Appearance(k) => enter |= crate::dialogs_appearance::rows(app, ui, k, &mut d.inputs), + Kind::Motion(k) => enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs), Kind::ConfirmDelete { with, fail, .. } => { if !with.is_empty() { row_label(ui, "Also deletes"); @@ -1476,10 +1482,16 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } if ok { match run_dialog(app, &d) { - Ok(()) => cancel = true, + Ok(()) => { + cancel = true; + applied = true; + } Err(e) => d.error = Some(e), } } + if !(keep && !cancel) { + crate::dialogs_motion::closed(app, &mut d, applied); + } if keep && !cancel { app.dialog = Some(d); } else if let Some((_, marker)) = d.editing { @@ -1490,6 +1502,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { /// Close the dialog without applying it (Esc, Cancel). An edit puts the timeline marker back. pub fn cancel(app: &mut SolveApp) { + if let Some(mut d) = app.dialog.take() { + crate::dialogs_motion::closed(app, &mut d, false); + app.dialog = Some(d); + } if let Some(d) = app.dialog.take() && let Some((_, marker)) = d.editing { @@ -2028,6 +2044,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S Kind::Sheet(k) => return crate::dialogs_sheet::commands(app, k, &d.inputs, &d.extra), Kind::Plastic(k) => return crate::dialogs_plastic::commands(app, k, &d.inputs, &d.extra), Kind::Appearance(k) => return Ok(crate::dialogs_appearance::commands(k, &d.inputs)), + Kind::Motion(k) => return crate::dialogs_motion::commands(app, k, &d.inputs), }; let mut params = params; if let Value::Object(m) = &mut params { diff --git a/crates/ui-egui/src/dialogs_motion.rs b/crates/ui-egui/src/dialogs_motion.rs new file mode 100644 index 00000000..b9e08397 --- /dev/null +++ b/crates/ui-egui/src/dialogs_motion.rs @@ -0,0 +1,709 @@ +//! Assembly motion dialogs: Contact Sets, Motion Study (keyframes on a timeline with playback), +//! Exploded View (a scale slider) and Configurations (a table of rows with Activate). What they +//! show while open (a study's pose, an explosion) is display only: pending occurrence moves, +//! computed on a scratch copy of the design and put back when the dialog closes. + +use std::collections::BTreeMap; + +use egui::{Color32, Pos2, RichText, Sense, Stroke, pos2, vec2}; +use serde_json::{Value, json}; +use solvecraft_engine::Session; +use solvecraft_engine::doc::Mat; +use solvecraft_engine::doc::joints::JointKind; + +use crate::SolveApp; +use crate::dialogs::{Dialog, Kind, combo, field, row_label}; +use crate::selection::{BODIES, SelInput}; +use crate::theme::Tokens; + +/// One keyed value of a motion study: a joint's value (index) at steps. +#[derive(Clone, Debug, PartialEq)] +pub struct Track { + /// Index into the design's joints. + pub joint: usize, + pub index: usize, + /// (step, value expression), in step order. + pub points: Vec<(u32, String)>, +} + +#[derive(Clone, Debug)] +pub enum Mo { + ContactSet { + name: String, + /// The last collision check. + check: Option, + }, + MotionStudy { + name: String, + /// Editing this study (its name). + study: Option, + steps: String, + tracks: Vec, + step: f64, + playing: bool, + /// The step the shown pose is for (and its revision). + shown: Option<(u64, u64)>, + saved: Option>, + error: Option, + }, + Explode { + name: String, + scale: f64, + shown: Option<(u64, u64)>, + saved: Option>, + error: Option, + }, + Configurations { + new_row: String, + new_column: usize, + error: Option, + }, +} + +impl Mo { + pub fn title(&self) -> &'static str { + match self { + Mo::ContactSet { .. } => "CONTACT SET", + Mo::MotionStudy { study: Some(_), .. } => "EDIT MOTION STUDY", + Mo::MotionStudy { .. } => "MOTION STUDY", + Mo::Explode { .. } => "EXPLODED VIEW", + Mo::Configurations { .. } => "CONFIGURATIONS", + } + } + + /// A table that needs the wide dialog. + pub fn wide(&self) -> bool { + matches!(self, Mo::Configurations { .. } | Mo::MotionStudy { .. }) + } +} + +/// The dialog for a motion command. +pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { + let n = |prefix: &str, list: &str| -> String { + let taken: Vec = app + .session + .scratch() + .execute(list, &json!({})) + .ok() + .and_then(|v| v.as_object().and_then(|o| o.values().find_map(|x| x.as_array().cloned()))) + .unwrap_or_default() + .iter() + .filter_map(|x| x.get("name").and_then(Value::as_str).map(str::to_string)) + .collect(); + (1..).map(|k| format!("{prefix}{k}")).find(|c| !taken.contains(c)).unwrap_or_default() + }; + let (mo, inputs) = match id { + "ContactSetCmd" => (Mo::ContactSet { name: n("Contact Set", "contact.list"), check: None }, vec![SelInput::new("Components", BODIES, true)]), + "FusionMotionStudyCommand" => ( + Mo::MotionStudy { + name: n("Motion Study", "motion.list"), + study: None, + steps: "60".into(), + tracks: Vec::new(), + step: 0.0, + playing: false, + shown: None, + saved: None, + error: None, + }, + vec![], + ), + "explode.create" => (Mo::Explode { name: n("Exploded View", "explode.list"), scale: 1.0, shown: None, saved: None, error: None }, vec![]), + "FusionShowDesignConfigPanelCmd" | "FusionStartDesignConfigModeCmd" => { + (Mo::Configurations { new_row: String::new(), new_column: 0, error: None }, vec![]) + } + _ => return None, + }; + Some((Kind::Motion(mo), inputs)) +} + +// ---- pure mappings (tested) ---- + +/// The study command's parameters. +pub fn study_params(s: &Session, name: &str, study: Option<&str>, steps: &str, tracks: &[Track]) -> Result { + let steps: u32 = steps.trim().parse().map_err(|_| "steps must be a whole number".to_string())?; + let keys: Vec = tracks + .iter() + .filter(|t| !t.points.is_empty()) + .map(|t| { + let j = s.doc.assembly.joints.get(t.joint).ok_or("no such joint")?; + Ok(json!({"joint": j.id, "index": t.index, "points": t.points.iter().map(|(st, v)| json!([st, v])).collect::>()})) + }) + .collect::>()?; + if keys.is_empty() { + return Err("add a key for a joint first".into()); + } + let mut p = json!({"name": name, "steps": steps, "keys": keys}); + if let Some(st) = study { + p["study"] = json!(st); + } + Ok(p) +} + +/// The configurations table: columns and rows (name, value per column). +pub struct Table { + pub columns: Vec, + pub rows: Vec<(String, Vec)>, + pub active: Option, +} + +pub fn table(v: &Value) -> Table { + let columns: Vec = + v.get("columns").and_then(Value::as_array).into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect(); + let cell = |x: Option<&Value>| match x { + Some(Value::String(s)) => s.clone(), + Some(Value::Bool(b)) => { + if *b { + "suppressed".into() + } else { + "on".into() + } + } + Some(Value::Null) | None => String::new(), + Some(o) => o.to_string(), + }; + let rows = v + .get("rows") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|r| { + let name = r.get("name")?.as_str()?.to_string(); + let vals = r.get("values"); + Some((name, columns.iter().map(|c| cell(vals.and_then(|m| m.get(c)))).collect())) + }) + .collect(); + Table { columns, rows, active: v.get("active").and_then(Value::as_str).map(str::to_string) } +} + +/// A column key ("param:width", "suppress:Fillet1") as (kind, name). +pub fn column_parts(c: &str) -> (&str, &str) { + c.split_once(':').unwrap_or(("param", c)) +} + +/// The value a table cell edit sends: suppress columns take booleans, parameters expressions. +pub fn cell_value(column: &str, text: &str) -> Value { + match column_parts(column).0 { + "suppress" => json!(matches!(text.trim().to_ascii_lowercase().as_str(), "suppressed" | "true" | "yes" | "1" | "off")), + _ => json!(text), + } +} + +// ---- display ---- + +/// Show `moves` as pending occurrence moves (display only), keeping what was there before. +fn display(app: &mut SolveApp, saved: &mut Option>, moves: BTreeMap) { + if saved.is_none() { + *saved = Some(app.session.pending_moves.clone()); + } + app.session.pending_moves = moves; + app.session.revision += 1; +} + +fn restore(app: &mut SolveApp, saved: &mut Option>) { + if let Some(m) = saved.take() { + app.session.pending_moves = m; + app.session.revision += 1; + } +} + +/// The dialog closed (`applied`: by OK): what it showed goes away, unless OK made it the view. +pub fn closed(app: &mut SolveApp, d: &mut Dialog, applied: bool) { + let Kind::Motion(k) = &mut d.kind else { return }; + match k { + Mo::MotionStudy { saved, .. } => restore(app, saved), + // OK shows the new view itself (explode.show). + Mo::Explode { saved, .. } if !applied => restore(app, saved), + _ => {} + } +} + +/// The occurrence placements of `after` that differ from `before` (as pending moves). +fn moved(before: &Session, after: &Session) -> BTreeMap { + let mut out = before.pending_moves.clone(); + for o in &after.doc.occurrences { + let was = before.doc.occurrences.iter().find(|b| b.id == o.id).map(|b| b.transform); + if was != Some(o.transform) { + out.insert(o.id, o.transform); + } + } + out +} + +/// The study's pose at `step`, computed on a scratch copy. +fn study_pose(app: &SolveApp, params: &Value, step: u32) -> Result, String> { + let mut sc = app.session.scratch(); + let name = params["study"].as_str().or(params["name"].as_str()).unwrap_or_default().to_string(); + sc.execute("FusionMotionStudyCommand", params).map_err(|e| e.to_string())?; + sc.execute("motion.play", &json!({"study": name, "step": step})).map_err(|e| e.to_string())?; + Ok(moved(&app.session, &sc)) +} + +/// The explosion at `scale`, computed on a scratch copy. +fn explode_pose(app: &SolveApp, scale: f64) -> Result, String> { + let mut sc = app.session.scratch(); + sc.execute("explode.create", &json!({"name": "__preview", "scale": scale})).map_err(|e| e.to_string())?; + sc.execute("explode.show", &json!({ "view": "__preview" })).map_err(|e| e.to_string())?; + Ok(sc.pending_moves.clone()) +} + +// ---- rows ---- + +fn joints_moving(app: &SolveApp) -> Vec<(usize, String, JointKind)> { + app.session.doc.assembly.joints.iter().enumerate().filter(|(_, j)| j.kind != JointKind::Rigid).map(|(i, j)| (i, j.name.clone(), j.kind)).collect() +} + +/// The dialog's rows; true when Enter was pressed in a value. +pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [SelInput]) -> bool { + let t = Tokens::get(); + let mut enter = false; + match k { + Mo::ContactSet { name, check } => { + row_label(ui, "Name"); + ui.text_edit_singleline(name); + ui.end_row(); + let list = app.session.execute("contact.list", &json!({})).unwrap_or(Value::Null); + row_label(ui, "Contact"); + let mut on = list.get("enabled").and_then(Value::as_bool).unwrap_or(false); + if ui.checkbox(&mut on, "enabled").changed() { + let _ = app.run("EnableContactSetsCmd", json!({ "enabled": on })); + } + ui.end_row(); + for s in list.get("sets").and_then(Value::as_array).cloned().unwrap_or_default() { + let n = s.get("name").and_then(Value::as_str).unwrap_or("").to_string(); + let off = s.get("suppressed").and_then(Value::as_bool).unwrap_or(false); + row_label(ui, &n); + ui.horizontal(|ui| { + let count = s.get("occurrences").and_then(Value::as_array).map_or(0, Vec::len); + ui.label(RichText::new(format!("{count} components")).color(t.text_dim)); + if ui.small_button(if off { "Unsuppress" } else { "Suppress" }).clicked() { + let _ = app.run("contact.edit", json!({"set": n, "suppressed": !off})); + } + if ui.small_button("×").on_hover_text("Delete the set").clicked() { + let _ = app.run("contact.edit", json!({"set": n, "delete": true})); + } + }); + ui.end_row(); + } + row_label(ui, ""); + if ui.button("Check collisions").clicked() { + *check = Some(app.session.execute("contact.check", &json!({})).unwrap_or_else(|e| json!({"error": e.to_string()}))); + } + ui.end_row(); + if let Some(c) = check { + let hits = c.get("collisions").and_then(Value::as_array).cloned().unwrap_or_default(); + row_label(ui, "Collisions"); + if let Some(e) = c.get("error").and_then(Value::as_str) { + ui.label(RichText::new(e).color(t.error)); + } else if hits.is_empty() { + ui.label(RichText::new("none").color(t.text_dim)); + } else { + ui.vertical(|ui| { + for h in hits { + ui.label( + RichText::new(format!( + "{} · {} {:.3} mm", + name_of(&h["a"]), + name_of(&h["b"]), + h["depth_mm"].as_f64().unwrap_or(0.0) + )) + .color(t.error), + ); + } + }); + } + ui.end_row(); + } + } + Mo::MotionStudy { name, study, steps, tracks, step, playing, shown, saved, error } => { + row_label(ui, "Name"); + ui.text_edit_singleline(name); + ui.end_row(); + row_label(ui, "Steps"); + enter |= field(ui, steps); + ui.end_row(); + let total = steps.trim().parse::().unwrap_or(60).clamp(1, 100_000); + let moving = joints_moving(app); + if moving.is_empty() { + row_label(ui, ""); + ui.label(RichText::new("no joint that moves yet").color(t.text_dim)); + ui.end_row(); + return enter; + } + // Playback. + row_label(ui, "Step"); + ui.horizontal(|ui| { + if ui.button(if *playing { "■" } else { "▶" }).on_hover_text(if *playing { "Stop" } else { "Play" }).clicked() { + *playing = !*playing; + } + ui.spacing_mut().slider_width = 220.0; + ui.add(egui::Slider::new(step, 0.0..=f64::from(total)).integer()); + }); + ui.end_row(); + if *playing { + let dt = ui.input(|i| i.stable_dt).min(0.1); + *step = (*step + f64::from(dt) * 24.0) % (f64::from(total) + 1.0); + ui.ctx().request_repaint(); + } + // Tracks: a row per keyed joint value, its keys on a strip under the steps. + let now = step.round() as u32; + let mut remove: Option = None; + for (ti, tr) in tracks.iter_mut().enumerate() { + let labels: Vec = moving.iter().map(|(_, n, _)| n.clone()).collect(); + let refs: Vec<&str> = labels.iter().map(String::as_str).collect(); + let mut pos = moving.iter().position(|(i, _, _)| *i == tr.joint).unwrap_or(0); + row_label(ui, &format!("Track {}", ti + 1)); + ui.horizontal(|ui| { + combo(ui, &format!("ms_joint{ti}"), &refs, &mut pos); + if ui.small_button("×").on_hover_text("Remove the track").clicked() { + remove = Some(ti); + } + }); + ui.end_row(); + if let Some((ji, _, kind)) = moving.get(pos) { + tr.joint = *ji; + let dofs = kind.dofs(); + if dofs.len() > 1 { + row_label(ui, " Value"); + let caps: Vec<&str> = dofs.to_vec(); + combo(ui, &format!("ms_idx{ti}"), &caps, &mut tr.index); + ui.end_row(); + } else { + tr.index = 0; + } + } + row_label(ui, " Keys"); + key_strip(ui, tr, total, now, &t); + ui.end_row(); + // The key at this step, if any: its value. + if let Some(p) = tr.points.iter_mut().find(|(s, _)| *s == now) { + row_label(ui, &format!(" At step {now}")); + enter |= field(ui, &mut p.1); + ui.end_row(); + } + row_label(ui, ""); + ui.horizontal(|ui| { + if ui.small_button("Key here").on_hover_text("Add a key at this step with the joint's value now").clicked() + && !tr.points.iter().any(|(s, _)| *s == now) + { + let unit = app + .session + .doc + .assembly + .joints + .get(tr.joint) + .map(|j| if j.kind.is_angle(tr.index) { "deg" } else { "mm" }) + .unwrap_or("mm"); + tr.points.push((now, format!("0 {unit}"))); + tr.points.sort_by_key(|p| p.0); + } + if ui.small_button("Delete key").clicked() { + tr.points.retain(|(s, _)| *s != now); + } + }); + ui.end_row(); + } + if let Some(i) = remove { + tracks.remove(i); + } + row_label(ui, ""); + if ui.button("Add Key").on_hover_text("Key a joint's value at this step").clicked() + && let Some((ji, _, kind)) = moving.first() + { + let unit = if kind.is_angle(0) { "deg" } else { "mm" }; + tracks.push(Track { joint: *ji, index: 0, points: vec![(now, format!("0 {unit}"))] }); + } + ui.end_row(); + // Show the pose at this step (again when the step or the keys change). + let params = study_params(&app.session, name, study.as_deref(), steps, tracks); + let key = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + format!("{params:?}").hash(&mut h); + h.finish() + }; + if *shown != Some((u64::from(now), key)) { + match params.and_then(|p| study_pose(app, &p, now)) { + Ok(m) => { + display(app, saved, m); + *error = None; + } + Err(e) => *error = Some(e), + } + *shown = Some((u64::from(now), key)); + } + if let Some(e) = error { + row_label(ui, ""); + ui.label(RichText::new(e.as_str()).color(t.text_dim)); + ui.end_row(); + } + } + Mo::Explode { name, scale, shown, saved, error } => { + row_label(ui, "Name"); + ui.text_edit_singleline(name); + ui.end_row(); + row_label(ui, "Explode"); + ui.spacing_mut().slider_width = 160.0; + ui.add(egui::Slider::new(scale, 0.0..=2.0).fixed_decimals(2)); + ui.end_row(); + let key = ((*scale * 1000.0).round() as u64, 0); + if *shown != Some(key) { + match explode_pose(app, *scale) { + Ok(m) => { + display(app, saved, m); + *error = None; + } + Err(e) => *error = Some(e), + } + *shown = Some(key); + } + if let Some(e) = error { + row_label(ui, ""); + ui.label(RichText::new(e.as_str()).color(t.error)); + ui.end_row(); + } + } + Mo::Configurations { new_row, new_column, error } => { + let v = app.session.execute("FusionShowDesignConfigPanelCmd", &json!({})).unwrap_or(Value::Null); + let tb = table(&v); + let run = |app: &mut SolveApp, error: &mut Option, id: &str, p: Value| { + *error = app.run(id, p).err(); + }; + // Header. + ui.label(RichText::new("Configuration").strong()); + ui.horizontal(|ui| { + for c in &tb.columns { + let (kind, n) = column_parts(c); + ui.add_sized(vec2(96.0, 18.0), egui::Label::new(RichText::new(n).strong()).truncate()) + .on_hover_text(format!("{} {n}", if kind == "suppress" { "Suppress" } else { "Parameter" })); + if ui.small_button("×").on_hover_text("Remove the column").clicked() { + let key = if kind == "suppress" { "feature" } else { "param" }; + run(app, error, "config.column", json!({ key: n, "delete": true })); + } + } + }); + ui.end_row(); + for (name, vals) in &tb.rows { + let active = tb.active.as_deref() == Some(name.as_str()); + ui.horizontal(|ui| { + if active { + ui.label(RichText::new("Active").color(t.accent)); + } else if ui.button("Activate").clicked() { + run(app, error, "config.activate", json!({ "row": name })); + } + ui.label(if active { RichText::new(name).strong() } else { RichText::new(name) }); + }); + ui.horizontal(|ui| { + for (c, val) in tb.columns.iter().zip(vals) { + let id = egui::Id::new(("cfg", name, c)); + let mut text = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| val.clone()); + let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(96.0)); + if r.changed() { + ui.data_mut(|d| d.insert_temp(id, text.clone())); + } + if r.lost_focus() { + ui.data_mut(|d| d.remove::(id)); + if text != *val { + let (_, n) = column_parts(c); + run(app, error, "config.row", json!({"name": name, "values": { n: cell_value(c, &text) }})); + } + } + ui.add_space(18.0); + } + if ui.small_button("×").on_hover_text("Delete the row").clicked() { + run(app, error, "config.row", json!({"name": name, "delete": true})); + } + }); + ui.end_row(); + } + // New row (a copy of the active one) and new column. + ui.add(egui::TextEdit::singleline(new_row).hint_text("new configuration").desired_width(130.0)); + ui.horizontal(|ui| { + if ui.button("Add Row").clicked() && !new_row.trim().is_empty() { + let mut p = json!({ "name": new_row.trim() }); + if let Some(a) = &tb.active { + p["from"] = json!(a); + } + run(app, error, "config.row", p); + new_row.clear(); + } + let mut options: Vec<(String, Value)> = Vec::new(); + for p in app.session.doc.params.iter().filter(|p| !p.model) { + if !tb.columns.contains(&format!("param:{}", p.name)) { + options.push((format!("Parameter {}", p.name), json!({ "param": p.name }))); + } + } + for f in &app.session.doc.features { + if !tb.columns.contains(&format!("suppress:{}", f.name)) { + options.push((format!("Suppress {}", f.name), json!({ "feature": f.name }))); + } + } + if !options.is_empty() { + let labels: Vec<&str> = options.iter().map(|o| o.0.as_str()).collect(); + *new_column = (*new_column).min(labels.len() - 1); + combo(ui, "cfg_col", &labels, new_column); + if ui.button("Add Column").clicked() + && let Some((_, p)) = options.get(*new_column) + { + let id = if tb.columns.is_empty() { "FusionStartDesignConfigModeCmd" } else { "config.column" }; + let p = if tb.columns.is_empty() { + match p.get("param") { + Some(n) => json!({ "params": [n] }), + None => json!({ "features": [p.get("feature")] }), + } + } else { + p.clone() + }; + run(app, error, id, p); + } + } + }); + ui.end_row(); + if let Some(e) = error { + ui.label(""); + ui.label(RichText::new(e.as_str()).color(t.error)); + ui.end_row(); + } + } + } + let _ = inputs; + enter +} + +/// A JSON name or id as text. +fn name_of(v: &Value) -> String { + v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()) +} + +/// A track's keys as diamonds over the steps, with the playhead. +fn key_strip(ui: &mut egui::Ui, tr: &Track, total: u32, now: u32, t: &Tokens) { + let (r, _) = ui.allocate_exact_size(vec2(240.0, 16.0), Sense::hover()); + let p = ui.painter(); + p.rect_filled(r, 3.0, t.field); + let x = |s: u32| r.left() + 6.0 + (r.width() - 12.0) * s as f32 / total.max(1) as f32; + // Keys joined by a line (the motion between them). + let pts: Vec = tr.points.iter().map(|(s, _)| pos2(x(*s), r.center().y)).collect(); + if pts.len() > 1 { + p.add(egui::Shape::line(pts.clone(), Stroke::new(1.5, t.accent.gamma_multiply(0.6)))); + } + for q in pts { + p.add(egui::Shape::convex_polygon( + vec![q + vec2(0.0, -5.0), q + vec2(5.0, 0.0), q + vec2(0.0, 5.0), q + vec2(-5.0, 0.0)], + t.accent, + Stroke::new(1.0, Color32::WHITE), + )); + } + let px = x(now); + p.line_segment([pos2(px, r.top()), pos2(px, r.bottom())], Stroke::new(1.5, t.warning)); +} + +/// The commands OK runs. +pub fn commands(app: &SolveApp, k: &Mo, inputs: &[SelInput]) -> Result, String> { + Ok(match k { + Mo::ContactSet { name, .. } => { + let mut occs: Vec = Vec::new(); + for x in inputs.first().map(|i| i.items.as_slice()).unwrap_or(&[]) { + if let solvecraft_engine::Sel::Body { name } = x + && let Some(b) = app.session.model.state().body(name) + { + let comp = app.session.doc.body_component(&b.name, b.feature); + if let Some(o) = app.session.doc.occurrence_of(comp).map(|o| o.id) + && !occs.contains(&o) + { + occs.push(o); + } + } + } + if occs.is_empty() { + // Only managing the sets: nothing to add. + return Ok(Vec::new()); + } + vec![("ContactSetCmd".into(), json!({"name": name, "occurrences": occs}))] + } + Mo::MotionStudy { name, study, steps, tracks, .. } => { + vec![("FusionMotionStudyCommand".into(), study_params(&app.session, name, study.as_deref(), steps, tracks)?)] + } + Mo::Explode { name, scale, .. } => { + vec![("explode.create".into(), json!({"name": name, "scale": scale})), ("explode.show".into(), json!({ "view": name }))] + } + Mo::Configurations { .. } => Vec::new(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// What a dialog shows goes away when it closes; an exploded view applied with OK stays. + #[test] + fn shown_poses_are_put_back() { + let mut app = SolveApp::new(Session::default(), crate::Services::default()); + app.session.pending_moves.insert(9, solvecraft_engine::doc::IDENTITY); + let before = app.session.pending_moves.clone(); + let mut m = solvecraft_engine::doc::IDENTITY; + m[3] = [5.0, 0.0, 0.0, 1.0]; + let shown: BTreeMap = [(3, m)].into_iter().collect(); + for (kind, applied, kept) in [("study", true, false), ("explode", false, false), ("explode", true, true)] { + let mut saved = None; + display(&mut app, &mut saved, shown.clone()); + assert_eq!(app.session.pending_moves, shown); + let mo = if kind == "study" { + Mo::MotionStudy { + name: "S".into(), + study: None, + steps: "10".into(), + tracks: vec![], + step: 0.0, + playing: false, + shown: None, + saved, + error: None, + } + } else { + Mo::Explode { name: "E".into(), scale: 1.0, shown: None, saved, error: None } + }; + let mut d = Dialog::new(Kind::Motion(mo), vec![]); + closed(&mut app, &mut d, applied); + assert_eq!(app.session.pending_moves == shown, kept, "{kind} {applied}"); + app.session.pending_moves = before.clone(); + } + } + + #[test] + fn configuration_table_and_cells() { + let v = json!({"columns": ["param:width", "suppress:Fillet1"], "rows": [ + {"name": "Default", "values": {"param:width": "80 mm", "suppress:Fillet1": false}}, + {"name": "Long", "values": {"param:width": "120 mm", "suppress:Fillet1": true}}, + ], "active": "Long"}); + let t = table(&v); + assert_eq!(t.columns.len(), 2); + assert_eq!(t.rows[1], ("Long".to_string(), vec!["120 mm".to_string(), "suppressed".to_string()])); + assert_eq!(t.active.as_deref(), Some("Long")); + assert_eq!(column_parts("suppress:Fillet1"), ("suppress", "Fillet1")); + assert_eq!(cell_value("suppress:Fillet1", "Suppressed"), json!(true)); + assert_eq!(cell_value("suppress:Fillet1", "on"), json!(false)); + assert_eq!(cell_value("param:width", "90 mm"), json!("90 mm")); + } + + #[test] + fn study_keys_by_joint_id() { + let mut s = Session::default(); + for (name, x) in [("A", 0), ("B", 30)] { + s.execute("component.activate", &json!({"component": "root"})).unwrap(); + s.execute("FusionCreateNewComponentCommand", &json!({ "name": name })).unwrap(); + s.execute("PrimitiveBox", &json!({"length": 20, "width": 20, "height": 10, "corner": [x, 0, 0]})).unwrap(); + } + s.execute( + "JointAssembleCmdNew", + &json!({"type": "revolute", "a": {"occurrence": "A:1", "face": [10, 10, 10]}, "b": {"occurrence": "B:1", "face": [40, 10, 0]}}), + ) + .unwrap(); + let tracks = vec![Track { joint: 0, index: 0, points: vec![(0, "0 deg".into()), (30, "90 deg".into())] }]; + let p = study_params(&s, "Study1", None, "60", &tracks).unwrap(); + assert_eq!(p["steps"], 60); + assert_eq!(p["keys"][0]["joint"], s.doc.assembly.joints[0].id); + assert_eq!(p["keys"][0]["points"], json!([[0, "0 deg"], [30, "90 deg"]])); + assert!(study_params(&s, "S", None, "x", &tracks).is_err()); + assert!(study_params(&s, "S", None, "10", &[]).is_err()); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ff45cda6..ec45cfd5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -17,6 +17,7 @@ pub mod delete; pub mod dialogs; pub mod dialogs_appearance; pub mod dialogs_assembly; +pub mod dialogs_motion; pub mod dialogs_plastic; pub mod dialogs_sheet; pub mod dim_view; diff --git a/crates/ui-egui/src/workspace.rs b/crates/ui-egui/src/workspace.rs index 7a86629e..2d67a32f 100644 --- a/crates/ui-egui/src/workspace.rs +++ b/crates/ui-egui/src/workspace.rs @@ -15,6 +15,12 @@ const ASSEMBLE: &[&str] = &[ "RigidGroupCmd", "FusionMoveJointsCommand", "FusionMotionRelationshipCommand", + "FusionMotionStudyCommand", + "explode.create", + "EnableContactSetsCmd", + "EnableAllContactCmd", + "DisableAllContactCmd", + "ContactSetCmd", "InterferenceCheckCommand", ]; @@ -83,6 +89,7 @@ const PLASTIC_CREATE: &[&str] = &[ /// Panels of a tab, when this module lays the tab out. pub fn panels(tab: &str) -> Option<&'static [&'static str]> { match tab { + "SOLID" => Some(&["CREATE", "MODIFY", "ASSEMBLE", "CONFIGURE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"]), "PLASTIC" => Some(&["SETUP", "CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"]), _ => None, } diff --git a/crates/ui-egui/tests/scenarios.rs b/crates/ui-egui/tests/scenarios.rs index 654d9676..e842a9d5 100644 --- a/crates/ui-egui/tests/scenarios.rs +++ b/crates/ui-egui/tests/scenarios.rs @@ -43,7 +43,8 @@ scenarios!( s18_3d_sketch_pipe, s19_canvas_calibrate, s20_section_with_joint_drive, - s21_named_views + s21_named_views, + s26_drive_joint_typed ); /// Scenarios written ahead of their features (the commands are in solvecraft-params' unpushed diff --git a/crates/ui-egui/tests/scenarios/s26_drive_joint_typed.json b/crates/ui-egui/tests/scenarios/s26_drive_joint_typed.json new file mode 100644 index 00000000..35afb905 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s26_drive_joint_typed.json @@ -0,0 +1,243 @@ +[ + { + "start": "FusionCreateNewComponentCommand" + }, + { + "key": "Enter" + }, + { + "start": "SketchCreate" + }, + { + "click": { + "plane": "XY" + } + }, + { + "start": "ShapeRectangleTwoPoint" + }, + { + "click": { + "sketch": [ + 0, + 0 + ] + } + }, + { + "click": { + "sketch": [ + 20, + 20 + ] + } + }, + { + "key": "Escape" + }, + { + "start": "SketchStop" + }, + { + "call": "ui.view", + "params": { + "view": "iso" + } + }, + { + "start": "Extrude" + }, + { + "text": "10" + }, + { + "key": "Enter" + }, + { + "call": "ui.menu", + "params": { + "x": 100, + "y": 170, + "target": { + "type": "component", + "id": 0 + } + } + }, + { + "call": "ui.menuPick", + "params": { + "item": "Activate" + } + }, + { + "start": "FusionCreateNewComponentCommand" + }, + { + "key": "Enter" + }, + { + "start": "SketchCreate" + }, + { + "click": { + "plane": "XZ" + } + }, + { + "start": "ShapeRectangleTwoPoint" + }, + { + "click": { + "sketch": [ + 30, + 0 + ] + } + }, + { + "click": { + "sketch": [ + 50, + 10 + ] + } + }, + { + "key": "Escape" + }, + { + "start": "SketchStop" + }, + { + "call": "ui.view", + "params": { + "view": "iso" + } + }, + { + "start": "Extrude" + }, + { + "click": { + "world": [ + 40, + 0, + 5 + ] + } + }, + { + "text": "20" + }, + { + "key": "Enter" + }, + { + "expect": { + "bodies": 2, + "errors": 0 + } + }, + { + "start": "JointAssembleCmdNew" + }, + { + "click": { + "world": [ + 10, + 10, + 10 + ] + } + }, + { + "click": { + "world": [ + 40, + 0, + 5 + ] + } + }, + { + "widget": "Rigid" + }, + { + "widget": "Revolute" + }, + { + "shot": "s07_joint" + }, + { + "widget": "OK" + }, + { + "expect": { + "dialog": null, + "errors": 0, + "query": { + "command": "joint.list", + "path": [ + "joints", + 0, + "type" + ], + "value": "revolute" + } + } + }, + { + "start": "FusionMoveJointsCommand" + }, + { + "text": "45" + }, + { + "key": "Enter" + }, + { + "expect": { + "dialog": null, + "errors": 0, + "query": { + "command": "joint.list", + "path": [ + "joints", + 0, + "values", + 0, + "value" + ], + "value": 45 + } + } + }, + { + "start": "FusionMoveJointsCommand" + }, + { + "text": "-30 deg" + }, + { + "key": "Enter" + }, + { + "expect": { + "query": { + "command": "joint.list", + "path": [ + "joints", + 0, + "values", + 0, + "value" + ], + "value": -30 + } + } + }, + { + "shot": "s26_drive_typed" + } +] \ No newline at end of file