Dialogs for contact sets, motion studies, exploded views and configurations; Drive scenario

Wired to solvecraft-params' contact, motion, explode and configuration commands (verified under Xvfb: a study keyed 0 to 60 deg plays, an explosion previews and OK keeps it, Activate switches configurations, Check collisions lists overlaps):
- Contact Set: components to a new set; the sets with suppress and delete; contact on/off;
  Check collisions (contact.check).
- Motion Study: tracks of keyed joint values on a strip under the steps, keys added and edited
  at the playhead, a step slider with Play; the pose at the step is shown (motion.play on a
  scratch copy, as pending moves) and put back when the dialog closes.
- Exploded View: a scale slider showing the explosion live; OK creates and shows the view.
- Configurations: the table (rows by columns of parameters and suppressed features), cells
  edited in place, Activate per row, rows and columns added and removed.
The ASSEMBLE panel lists the motion and contact commands once they exist. Tests for the pose,
table and study mappings.

Scenario s13: Drive Joints takes typed input without a click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 12:02:55 +00:00
1 parent 46f3ff713d
commit 5096bf2442
6 files changed
+982 -4

No files matched your search

+20 -3
View File
@@ -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<Vec<(String, Value)>, 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 {
+709
View File
@@ -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<Value>,
},
MotionStudy {
name: String,
/// Editing this study (its name).
study: Option<String>,
steps: String,
tracks: Vec<Track>,
step: f64,
playing: bool,
/// The step the shown pose is for (and its revision).
shown: Option<(u64, u64)>,
saved: Option<BTreeMap<u64, Mat>>,
error: Option<String>,
},
Explode {
name: String,
scale: f64,
shown: Option<(u64, u64)>,
saved: Option<BTreeMap<u64, Mat>>,
error: Option<String>,
},
Configurations {
new_row: String,
new_column: usize,
error: Option<String>,
},
}
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<SelInput>)> {
let n = |prefix: &str, list: &str| -> String {
let taken: Vec<String> = 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<Value, String> {
let steps: u32 = steps.trim().parse().map_err(|_| "steps must be a whole number".to_string())?;
let keys: Vec<Value> = 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::<Vec<_>>()}))
})
.collect::<Result<_, &str>>()?;
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<String>,
pub rows: Vec<(String, Vec<String>)>,
pub active: Option<String>,
}
pub fn table(v: &Value) -> Table {
let columns: Vec<String> =
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<BTreeMap<u64, Mat>>, moves: BTreeMap<u64, Mat>) {
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<BTreeMap<u64, Mat>>) {
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<u64, Mat> {
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<BTreeMap<u64, Mat>, 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<BTreeMap<u64, Mat>, 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::<u32>().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<usize> = None;
for (ti, tr) in tracks.iter_mut().enumerate() {
let labels: Vec<String> = 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<String>, 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::<String>(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::<String>(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<Pos2> = 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<Vec<(String, Value)>, String> {
Ok(match k {
Mo::ContactSet { name, .. } => {
let mut occs: Vec<u64> = 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<u64, Mat> = [(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());
}
}
+1
View File
@@ -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;
+7
View File
@@ -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,
}
+2 -1
View File
@@ -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
@@ -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"
}
]