What a component holds moves with its occurrence, and picks in the world land in the
component's frame:
- Sketches and construction planes show where their component is placed (free move, joint
drive, Capture Position); sketch.inspect reports sketch points in the world.
- frames.rs maps a command's world picks into the frame of the body they lie on (through the
inverse of its placement: a moved component or a pasted instance), points that make new
geometry into the active component's frame, and directions with them; instance body names
("Body1 (Pin:2)") become the component's body. Fillet/chamfer edges, shell/offset/draft
faces, face extrude/revolve, sketch on face, holes, sheet metal and plastic picks, primitives,
appearance faces, analyses. Once per command (not again for commands run by commands); the
active-frame fallback stays for commands without a table. Nothing changes while every
placement is the identity.
- component.move_bodies keeps a body where it is in the world (Document.body_offsets: from the
new component's frame to the one it was made in; renames and deleted components follow).
- The UI reads the placed model (Session::world_state) for display, picking, sketch editing and
dialogs; live previews use it once anything is placed.
Tests (engine): sketches and construction planes move (and turn) with the component; world
picks fillet, sketch, hole and make boxes in a moved component; a pick on a pasted instance;
move_bodies keeps the world place there and back; a sketch on a face follows a Move feature.
QA's s34 now runs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
743 lines
32 KiB
Rust
743 lines
32 KiB
Rust
//! 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,
|
||
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: "100".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(), 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) }
|
||
}
|
||
|
||
/// The unit a value of this kind is shown in.
|
||
fn unit_of(k: solvecraft_engine::doc::expr::Kind) -> Option<&'static str> {
|
||
match k {
|
||
solvecraft_engine::doc::expr::Kind::Length => Some("mm"),
|
||
solvecraft_engine::doc::expr::Kind::Angle => Some("deg"),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// 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(100).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, " Of");
|
||
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) {
|
||
// The key's value (Enter commits it; OK makes the study).
|
||
row_label(ui, "Value");
|
||
let _ = 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, error } => {
|
||
let v = app.session.execute("FusionShowDesignConfigPanelCmd", &json!({})).unwrap_or(Value::Null);
|
||
let mut tb = table(&v);
|
||
// Bare numbers show with their parameter's unit ("40" is 40 mm), as Fusion shows them.
|
||
let kinds = app.session.doc.all_param_exprs();
|
||
for (_, vals) in &mut tb.rows {
|
||
for (c, val) in tb.columns.iter().zip(vals.iter_mut()) {
|
||
let (kind, n) = column_parts(c);
|
||
if kind == "param"
|
||
&& val.trim().parse::<f64>().is_ok()
|
||
&& let Some(unit) = kinds.iter().find(|x| x.0 == n).and_then(|x| unit_of(x.2))
|
||
{
|
||
*val = format!("{} {unit}", val.trim());
|
||
}
|
||
}
|
||
}
|
||
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| {
|
||
ui.label(if active { RichText::new(name).strong() } else { RichText::new(name) });
|
||
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.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").on_hover_text("A new configuration, a copy of the active one").clicked() {
|
||
let name = if new_row.trim().is_empty() {
|
||
(tb.rows.len() + 1..).map(|k| format!("Configuration{k}")).find(|n| !tb.rows.iter().any(|r| &r.0 == n)).unwrap_or_default()
|
||
} else {
|
||
new_row.trim().to_string()
|
||
};
|
||
let mut p = json!({ "name": name });
|
||
if let Some(a) = &tb.active {
|
||
p["from"] = json!(a);
|
||
}
|
||
run(app, error, "config.row", p);
|
||
new_row.clear();
|
||
}
|
||
// A column: a parameter's value or a feature's suppression.
|
||
let mut options: Vec<(String, Value)> = Vec::new();
|
||
for p in app.session.doc.all_param_exprs() {
|
||
if !tb.columns.contains(&format!("param:{}", p.0)) {
|
||
options.push((format!("{} (parameter)", p.0), json!({ "param": p.0 })));
|
||
}
|
||
}
|
||
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 })));
|
||
}
|
||
}
|
||
let mut pick: Option<Value> = None;
|
||
ui.menu_button("Add Column", |ui| {
|
||
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
|
||
for (label, p) in &options {
|
||
if ui.button(label.as_str()).clicked() {
|
||
pick = Some(p.clone());
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
if let Some(p) = pick {
|
||
// The first column starts the table (with a Default row of the current values).
|
||
let (id, p) = if tb.columns.is_empty() {
|
||
(
|
||
"FusionStartDesignConfigModeCmd",
|
||
match p.get("param") {
|
||
Some(n) => json!({ "params": [n] }),
|
||
None => json!({ "features": [p.get("feature")] }),
|
||
},
|
||
)
|
||
} else {
|
||
("config.column", p)
|
||
};
|
||
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.world_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());
|
||
}
|
||
}
|