Fold dialog (SHEET METAL > Fold); configurations and motion study dialogs pass their scenarios

Fold: the bend line (a sketch line), an optional stationary side, Bend Line Position
(centerline, start, end, outside mould line), angle (on the canvas), bend radius (default from
the rule) and flip; live preview and Edit Feature (folds made from two points keep them). Its
own icon; listed after Hem on the SHEET METAL tab and in the timeline.

Configurations: Add Column lists parameters and features to pick, Add Row names the new row
Configuration<N> when no name is typed, bare numbers show with their parameter's unit, the row
name comes before its Activate button, and Enter commits a cell instead of closing the panel.
Motion Study: steps default to the engine's 100, the key's value row is "Value" and Enter there
commits the key (OK makes the study).

Scenarios s22 (fold, flat pattern, DXF; the expected volume is the folded sheet's, the bend
keeping the neutral layer), s23 (configurations) and s24 (motion study) now run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 12:42:52 +00:00
1 parent 1c12fa83b0
commit 8dbecb462a
7 files changed
+318 -69

No files matched your search

+3 -1
View File
@@ -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;
}
+65 -32
View File
@@ -55,7 +55,6 @@ pub enum Mo {
},
Configurations {
new_row: String,
new_column: usize,
error: Option<String>,
},
}
@@ -98,7 +97,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
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<SelInput>)> {
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::<u32>().unwrap_or(60).clamp(1, 100_000);
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, "");
@@ -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::<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();
};
@@ -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<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 {
p.clone()
};
run(app, error, id, p);
}
},
)
} else {
("config.column", p)
};
run(app, error, id, p);
}
});
ui.end_row();
+86 -1
View File
@@ -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<SelInput>)> {
"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<String,
}
("FusionSheetMetalHemFlangeCommand", p)
}
Sm::Fold { angle, radius, position, flip } => {
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<SelI
edges.iter().filter_map(|p| edge_sel(s, *p)).collect(),
)
}
FeatureKind::SheetFold { curve, a, b, angle, radius, position, flip, fixed, body, .. } => {
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,
+7
View File
@@ -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);
+2
View File
@@ -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",
+4 -4
View File
@@ -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",
);
@@ -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"
}
}
]