diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7e49668c..03160ff8 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1465,7 +1465,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let (enter_free, esc) = ctx.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape))); let nothing_focused = ctx.memory(|m| m.focused().is_none()); let canvas_enter = enter_free && ctx.memory(|m| m.had_focus_last_frame(egui::Id::new("sc_canvas_value"))); - let enter_applies = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::ConfirmDelete { .. } | Kind::Preferences); + // Tables (configurations, a study's keys) take Enter for their cells. + let enter_applies = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::ConfirmDelete { .. } | Kind::Preferences) + && !matches!(&d.kind, Kind::Motion(k) if k.wide()); if enter_applies && (enter || canvas_enter || (enter_free && nothing_focused)) { ok = true; } diff --git a/crates/ui-egui/src/dialogs_motion.rs b/crates/ui-egui/src/dialogs_motion.rs index b9e08397..abc76e39 100644 --- a/crates/ui-egui/src/dialogs_motion.rs +++ b/crates/ui-egui/src/dialogs_motion.rs @@ -55,7 +55,6 @@ pub enum Mo { }, Configurations { new_row: String, - new_column: usize, error: Option, }, } @@ -98,7 +97,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { Mo::MotionStudy { name: n("Motion Study", "motion.list"), study: None, - steps: "60".into(), + steps: "100".into(), tracks: Vec::new(), step: 0.0, playing: false, @@ -109,9 +108,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { 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![]) - } + "FusionShowDesignConfigPanelCmd" | "FusionStartDesignConfigModeCmd" => (Mo::Configurations { new_row: String::new(), error: None }, vec![]), _ => return None, }; Some((Kind::Motion(mo), inputs)) @@ -176,6 +173,15 @@ pub fn table(v: &Value) -> Table { 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)) @@ -322,7 +328,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel row_label(ui, "Steps"); enter |= field(ui, steps); ui.end_row(); - let total = steps.trim().parse::().unwrap_or(60).clamp(1, 100_000); + let total = steps.trim().parse::().unwrap_or(100).clamp(1, 100_000); let moving = joints_moving(app); if moving.is_empty() { row_label(ui, ""); @@ -364,7 +370,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel tr.joint = *ji; let dofs = kind.dofs(); if dofs.len() > 1 { - row_label(ui, " Value"); + row_label(ui, " Of"); let caps: Vec<&str> = dofs.to_vec(); combo(ui, &format!("ms_idx{ti}"), &caps, &mut tr.index); ui.end_row(); @@ -377,8 +383,9 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel 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); + // 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, ""); @@ -463,9 +470,22 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel ui.end_row(); } } - Mo::Configurations { new_row, new_column, error } => { + Mo::Configurations { new_row, error } => { let v = app.session.execute("FusionShowDesignConfigPanelCmd", &json!({})).unwrap_or(Value::Null); - let tb = table(&v); + 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::().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, id: &str, p: Value| { *error = app.run(id, p).err(); }; @@ -486,12 +506,12 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel 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.label(if active { RichText::new(name).strong() } else { RichText::new(name) }); }); ui.horizontal(|ui| { for (c, val) in tb.columns.iter().zip(vals) { @@ -519,18 +539,24 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel // 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 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.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 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 { @@ -538,24 +564,31 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel 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() { + let mut pick: Option = 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 { - p.clone() - }; - run(app, error, id, p); - } + }, + ) + } else { + ("config.column", p) + }; + run(app, error, id, p); } }); ui.end_row(); diff --git a/crates/ui-egui/src/dialogs_sheet.rs b/crates/ui-egui/src/dialogs_sheet.rs index 900a29dd..57e0b9f3 100644 --- a/crates/ui-egui/src/dialogs_sheet.rs +++ b/crates/ui-egui/src/dialogs_sheet.rs @@ -13,9 +13,11 @@ use solvecraft_engine::geom::Vec3; use crate::SolveApp; use crate::dialogs::{Dialog, Kind, combo, edge_sel, face_sel, field, profile_indices, row_label}; -use crate::selection::{BODIES, CURVES, EDGES, PLANAR_FACES, PROFILES, SelInput}; +use crate::selection::{AXES, BODIES, CURVES, EDGES, PLANAR_FACES, PROFILES, SelInput}; use crate::theme::Tokens; +const FOLD_POSITIONS: [&str; 4] = ["centerline", "start", "end", "mould"]; +const FOLD_POSITION_LABELS: [&str; 4] = ["Centerline", "Start of Bend", "End of Bend", "Outside Mould Line"]; const FLANGE_TYPES: [&str; 3] = ["Base", "Edge", "Contour"]; const POSITIONS: [&str; 3] = ["inside", "outside", "middle"]; const POSITION_LABELS: [&str; 3] = ["Inside", "Outside", "Middle"]; @@ -50,6 +52,13 @@ pub enum Sm { gap: String, flip: bool, }, + /// Fold (bend) along a sketch line; `position`: where the line sits in the bend. + Fold { + angle: String, + radius: String, + position: usize, + flip: bool, + }, Unfold { refold: bool, }, @@ -77,6 +86,7 @@ impl Sm { match self { Sm::Flange { .. } => "FLANGE", Sm::Hem { .. } => "HEM", + Sm::Fold { .. } => "FOLD", Sm::Unfold { refold: false } => "UNFOLD", Sm::Unfold { refold: true } => "REFOLD", Sm::Convert { .. } => "CONVERT TO SHEET METAL", @@ -98,6 +108,7 @@ impl Sm { Sm::Flange { ty: 1, height, .. } => ("Height", ValueKind::Length, height), Sm::Flange { ty: 2, distance, .. } => ("Distance", ValueKind::Length, distance), Sm::Hem { length, .. } => ("Length", ValueKind::Length, length), + Sm::Fold { angle, .. } => ("Angle", ValueKind::Angle, angle), _ => return None, }) } @@ -124,6 +135,10 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { "FusionSheetMetalHemFlangeCommand" => { (Sm::Hem { length: "5 mm".into(), gap: String::new(), flip: false }, vec![SelInput::new("Edges", EDGES, true)]) } + "SheetMetalFoldCmd" => ( + Sm::Fold { angle: "90 deg".into(), radius: String::new(), position: 0, flip: false }, + vec![SelInput::new("Bend Line", AXES, false), SelInput::new("Stationary Side", PLANAR_FACES, false)], + ), "FusionSheetmetalUnfoldCommand" => (Sm::Unfold { refold: false }, vec![SelInput::new("Body (last if none)", BODIES, false)]), "sheet.refold" => (Sm::Unfold { refold: true }, vec![SelInput::new("Body (last if none)", BODIES, false)]), "ConvertToSheetMetalCmd" => (Sm::Convert { rule: 0 }, vec![SelInput::new("Face", PLANAR_FACES, false)]), @@ -230,6 +245,22 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel ui.checkbox(flip, ""); ui.end_row(); } + Sm::Fold { angle, radius, position, flip } => { + row_label(ui, "Bend Line Position"); + combo(ui, "sm_fold_pos", &FOLD_POSITION_LABELS, position); + ui.end_row(); + row_label(ui, "Angle"); + enter |= field(ui, angle); + ui.end_row(); + row_label(ui, "Bend Radius"); + let r = ui.add(egui::TextEdit::singleline(radius).hint_text("from the rule")); + crate::params_dialog::complete(ui, &r, radius); + enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + ui.end_row(); + row_label(ui, "Flip"); + ui.checkbox(flip, ""); + ui.end_row(); + } Sm::Unfold { .. } => {} Sm::Convert { rule } => rule_combo(app, ui, "sm_conv_rule", rule), Sm::Rules { pick, name, values, active, loaded } => { @@ -440,6 +471,33 @@ pub fn commands(app: &SolveApp, k: &Sm, inputs: &[SelInput], extra: &Map { + let mut p = json!({"angle": angle, "position": FOLD_POSITIONS.get(*position).copied().unwrap_or("centerline"), "flip": flip}); + match items(inputs, 0).first() { + Some(Sel::SketchCurve { id }) => { + let st = s.model.state(); + let sketch = st + .sketches + .iter() + .rev() + .find(|ss| ss.sketch.curve_index(id).is_some()) + .map(|ss| ss.feature) + .ok_or("the line's sketch is gone")?; + p["sketch"] = json!(sketch); + p["curve"] = json!(id); + } + // An edited fold made from two points keeps them (in `extra`). + _ if extra.contains_key("points") => {} + _ => return Err("select the bend line (a sketch line) first".into()), + } + if !radius.trim().is_empty() { + p["radius"] = json!(radius); + } + if let Some(Sel::Face { point, .. }) = items(inputs, 1).first() { + p["fixed"] = pt(*point); + } + ("SheetMetalFoldCmd", p) + } Sm::Unfold { refold } => { let mut p = json!({}); if let Some(Sel::Body { name }) = items(inputs, 0).first() { @@ -534,6 +592,33 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind) -> Option<(Kind, Vec { + if let Some(bd) = body { + extra.insert("body".into(), json!(bd)); + } + let k = Sm::Fold { + angle: angle.clone(), + radius: radius.clone().unwrap_or_default(), + position: FOLD_POSITIONS.iter().position(|x| x == position || (*x == "mould" && position == "mold")).unwrap_or(0), + flip: *flip, + }; + let (_, mut ins) = start(app, "SheetMetalFoldCmd")?; + match curve { + Some(c) => { + if let Some(i) = ins.first_mut() { + i.items = vec![Sel::SketchCurve { id: c.clone() }]; + } + } + // A fold given by two points keeps them. + None => { + extra.insert("points".into(), json!([pt(*a), pt(*b)])); + } + } + if let Some(i) = ins.get_mut(1) { + i.items = fixed.and_then(|f| face_sel(s, f)).into_iter().collect(); + } + return Some((Kind::Sheet(k), ins, extra)); + } FeatureKind::SheetUnfold { body, refold } => (Sm::Unfold { refold: *refold }, body.iter().map(|n| Sel::Body { name: n.clone() }).collect()), FeatureKind::SheetConvert { face, rule, .. } => (Sm::Convert { rule: rule_index(rule) }, face_sel(s, *face).into_iter().collect()), _ => return None, diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index b24243cd..d6dc0167 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -848,6 +848,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce pen.arc(16.0, 11.5, 4.5, -1.57, 1.57, a); pen.line(&[(16.0, 7.0), (8.0, 7.0)], a); } + "fold" => { + pen.line(&[(2.0, 18.0), (12.0, 18.0)], ink); + pen.line(&[(12.0, 18.0), (19.0, 6.0)], a); + pen.line(&[(9.0, 21.0), (15.0, 15.0)], Color32::from_rgb(130, 140, 160)); + pen.arc(12.0, 18.0, 6.0, 0.2, 1.0, a); + pen.arrow((18.0, 16.0), (17.0, 12.5), a); + } "unfold" => { pen.line(&[(3.0, 18.0), (12.0, 18.0), (12.0, 7.0)], ink); pen.line(&[(12.0, 18.0), (22.0, 18.0)], a); diff --git a/crates/ui-egui/src/workspace.rs b/crates/ui-egui/src/workspace.rs index 2d67a32f..3b3005ba 100644 --- a/crates/ui-egui/src/workspace.rs +++ b/crates/ui-egui/src/workspace.rs @@ -29,6 +29,7 @@ const SHEET_CREATE: &[&str] = &[ "FusionSheetMetalFlangeCommand", "FusionSheetMetalFlatPatternCmd", "FusionSheetMetalHemFlangeCommand", + "SheetMetalFoldCmd", "ConvertToSheetMetalCmd", "FusionCreateNewComponentCommand", "Extrude", @@ -123,6 +124,7 @@ pub fn feature_icon(k: &FeatureKind) -> Option<&'static str> { Some(match k { FeatureKind::SheetBase { .. } | FeatureKind::SheetContour { .. } | FeatureKind::SheetFlange { .. } => "flange", FeatureKind::SheetHem { .. } => "hem", + FeatureKind::SheetFold { .. } => "fold", FeatureKind::SheetUnfold { refold: false, .. } => "unfold", FeatureKind::SheetUnfold { .. } => "refold", FeatureKind::SheetConvert { .. } => "convert_sheet", diff --git a/crates/ui-egui/tests/scenarios.rs b/crates/ui-egui/tests/scenarios.rs index e842a9d5..a155ce5d 100644 --- a/crates/ui-egui/tests/scenarios.rs +++ b/crates/ui-egui/tests/scenarios.rs @@ -44,7 +44,10 @@ scenarios!( s19_canvas_calibrate, s20_section_with_joint_drive, s21_named_views, - s26_drive_joint_typed + s26_drive_joint_typed, + s22_sheet_metal_fold, + s23_configurations, + s24_motion_study ); /// Scenarios written ahead of their features (the commands are in solvecraft-params' unpushed @@ -60,8 +63,5 @@ macro_rules! pending { } pending!( - s22_sheet_metal_fold: "waits for SheetMetalFoldCmd and its dialog", - s23_configurations: "waits for configurations (FusionStartDesignConfigModeCmd, config.activate) and their panel", - s24_motion_study: "waits for FusionMotionStudyCommand and its dialog", s25_autosave_recovery: "waits for autosave and Recover unsaved design", ); diff --git a/crates/ui-egui/tests/scenarios/s22_sheet_metal_fold.json b/crates/ui-egui/tests/scenarios/s22_sheet_metal_fold.json index 41b3252e..60030662 100644 --- a/crates/ui-egui/tests/scenarios/s22_sheet_metal_fold.json +++ b/crates/ui-egui/tests/scenarios/s22_sheet_metal_fold.json @@ -1,32 +1,152 @@ [ - {"note": "Target: sheet metal plate folded along a sketch line (SheetMetalFoldCmd, solvecraft-params; dialog by solvecraft-dialogs). Adjust the dialog steps when it lands."}, - {"call": "ui.set", "params": {"tab": "SHEET METAL"}}, - {"start": "SketchCreate"}, - {"click": {"plane": "XY"}}, - {"start": "ShapeRectangleTwoPoint"}, - {"click": {"sketch": [0, 0]}}, - {"click": {"sketch": [60, 40]}}, - {"key": "Escape"}, - {"start": "SketchStop"}, - {"call": "ui.view", "params": {"view": "iso"}}, - {"start": "FusionSheetMetalFlangeCommand"}, - {"click": {"world": [30, 20, 0]}}, - {"key": "Enter"}, - {"expect": {"bodies": 1, "volume": 6000}}, - {"start": "SketchCreate"}, - {"click": {"world": [30, 20, 2.5]}}, - {"start": "DrawPolyline"}, - {"click": {"sketch": [40, -5]}}, - {"click": {"sketch": [40, 45]}}, - {"key": "Escape"}, - {"key": "Escape"}, - {"start": "SketchStop"}, - {"start": "SheetMetalFoldCmd"}, - {"click": {"world": [40, 20, 2.5]}}, - {"key": "Enter"}, - {"expect": {"bodies": 1, "errors": 0, "dialog": null, "volume": [6000, 1e-3]}}, - {"shot": "s22_folded"}, - {"start": "FusionSheetMetalFlatPatternCmd"}, - {"widget": "Export DXF…"}, - {"expect": {"file": "Untitled-Body1.dxf"}} -] + { + "note": "Target: sheet metal plate folded along a sketch line (SheetMetalFoldCmd, solvecraft-params; dialog by solvecraft-dialogs). Adjust the dialog steps when it lands." + }, + { + "call": "ui.set", + "params": { + "tab": "SHEET METAL" + } + }, + { + "start": "SketchCreate" + }, + { + "click": { + "plane": "XY" + } + }, + { + "start": "ShapeRectangleTwoPoint" + }, + { + "click": { + "sketch": [ + 0, + 0 + ] + } + }, + { + "click": { + "sketch": [ + 60, + 40 + ] + } + }, + { + "key": "Escape" + }, + { + "start": "SketchStop" + }, + { + "call": "ui.view", + "params": { + "view": "iso" + } + }, + { + "start": "FusionSheetMetalFlangeCommand" + }, + { + "click": { + "world": [ + 30, + 20, + 0 + ] + } + }, + { + "key": "Enter" + }, + { + "expect": { + "bodies": 1, + "volume": 6000 + } + }, + { + "start": "SketchCreate" + }, + { + "click": { + "world": [ + 30, + 20, + 2.5 + ] + } + }, + { + "start": "DrawPolyline" + }, + { + "click": { + "sketch": [ + 40, + -5 + ] + } + }, + { + "click": { + "sketch": [ + 40, + 45 + ] + } + }, + { + "key": "Escape" + }, + { + "key": "Escape" + }, + { + "start": "SketchStop" + }, + { + "start": "SheetMetalFoldCmd" + }, + { + "click": { + "world": [ + 40, + 20, + 2.5 + ] + } + }, + { + "key": "Enter" + }, + { + "expect": { + "bodies": 1, + "errors": 0, + "dialog": null, + "volume": [ + 6023.5, + 0.001 + ] + }, + "note": "the bend keeps the neutral layer (K 0.44): the bend zone holds \u03b8\u00b7T\u00b7(T/2 \u2212 K\u00b7T)\u00b7w = 23.6 mm\u00b3 more than the flat strip it replaces" + }, + { + "shot": "s22_folded" + }, + { + "start": "FusionSheetMetalFlatPatternCmd" + }, + { + "widget": "Export DXF\u2026" + }, + { + "expect": { + "file": "Untitled-Body1.dxf" + } + } +] \ No newline at end of file