Files
OneCAD/crates/ui-egui/src/dialogs_sheet.rs
T
bherbruckandClaude Opus 5.5 8dbecb462a 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 12:42:52 +00:00

753 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Sheet metal dialogs: Flange (base from profiles, edge flanges from picked edges, contour from
//! open sketch lines; the picks decide which), Hem, Unfold and Refold, Convert to Sheet Metal,
//! Sheet Metal Rules (the rule manager) and Create Flat Pattern (a flat view of the part with
//! its bend lines, the bend table and DXF export).
use egui::{Align2, Color32, FontId, Pos2, Rect, RichText, Stroke, pos2, vec2};
use serde_json::{Map, Value, json};
use solvecraft_engine::Sel;
use solvecraft_engine::doc::expr::Kind as ValueKind;
use solvecraft_engine::doc::sheet::SheetRule;
use solvecraft_engine::doc::{FeatureKind, ProfileSel};
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::{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"];
/// Rule fields the manager edits (parameter name, label).
const RULE_FIELDS: [(&str, &str); 8] = [
("thickness", "Thickness"),
("k_factor", "K Factor"),
("bend_radius", "Bend Radius"),
("relief_width", "Relief Width"),
("relief_depth", "Relief Depth"),
("corner_relief", "Corner Relief"),
("hem_gap", "Hem Gap"),
("gap", "Flange Gap"),
];
#[derive(Clone, Debug)]
pub enum Sm {
/// Base, edge or contour flange (`ty` follows the picks: profiles, edges, sketch lines).
Flange {
ty: usize,
height: String,
angle: String,
radius: String,
position: usize,
flip: bool,
distance: String,
reverse: bool,
rule: usize,
},
Hem {
length: String,
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,
},
Convert {
rule: usize,
},
/// The rule manager: the rule shown (index into the rules, or past the end for a new one).
Rules {
pick: usize,
name: String,
values: Vec<String>,
active: bool,
loaded: Option<usize>,
},
/// The flat pattern of the picked (or last) sheet body.
FlatPattern {
data: Option<Result<Value, String>>,
key: Option<(u64, Option<String>)>,
status: Option<String>,
},
}
impl Sm {
pub fn title(&self) -> &'static str {
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",
Sm::Rules { .. } => "SHEET METAL RULES",
Sm::FlatPattern { .. } => "FLAT PATTERN",
}
}
pub fn previews(&self) -> bool {
!matches!(self, Sm::FlatPattern { .. })
}
/// The value on the canvas (the flange's follows its picks).
pub fn primary(&mut self, inputs: &[SelInput]) -> Option<(&'static str, ValueKind, &mut String)> {
if let Sm::Flange { ty, .. } = self {
*ty = flange_type(inputs);
}
Some(match self {
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,
})
}
}
fn new_flange(ty: usize) -> Sm {
Sm::Flange {
ty,
height: "10 mm".into(),
angle: "90 deg".into(),
radius: String::new(),
position: 0,
flip: false,
distance: "20 mm".into(),
reverse: false,
rule: 0,
}
}
/// The dialog for a sheet metal command.
pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
let (sm, inputs) = match id {
"FusionSheetMetalFlangeCommand" => (new_flange(0), vec![SelInput::new("Profile / Edges", PROFILES | EDGES | CURVES, true)]),
"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)]),
"FusionSheetMetalRulesCommand" => {
let active = app.session.doc.sheet.active.clone();
let rules = rule_list(app);
let pick = active.and_then(|a| rules.iter().position(|r| r.name == a)).unwrap_or(0);
(Sm::Rules { pick, name: String::new(), values: Vec::new(), active: true, loaded: None }, vec![])
}
"FusionSheetMetalFlatPatternCmd" => {
(Sm::FlatPattern { data: None, key: None, status: None }, vec![SelInput::new("Body (last if none)", BODIES, false)])
}
_ => return None,
};
Some((Kind::Sheet(sm), inputs))
}
/// The design's rules (the default steel rule when it has none).
fn rule_list(app: &SolveApp) -> Vec<SheetRule> {
let r = &app.session.doc.sheet.rules;
if r.is_empty() { vec![SheetRule::steel()] } else { r.clone() }
}
/// "(active rule)" and the rule names, for a rule box.
fn rule_names(app: &SolveApp) -> Vec<String> {
std::iter::once("(active rule)".to_string()).chain(rule_list(app).into_iter().map(|r| r.name)).collect()
}
fn rule_combo(app: &SolveApp, ui: &mut egui::Ui, id: &str, rule: &mut usize) {
let names = rule_names(app);
let refs: Vec<&str> = names.iter().map(String::as_str).collect();
row_label(ui, "Rule");
combo(ui, id, &refs, rule);
ui.end_row();
}
/// The flange type the picks make: edges an edge flange, sketch lines a contour, else base.
fn flange_type(inputs: &[SelInput]) -> usize {
let items = inputs.first().map(|i| i.items.as_slice()).unwrap_or(&[]);
if items.iter().any(|x| matches!(x, Sel::Edge { .. })) {
1
} else if items.iter().any(|x| matches!(x, Sel::SketchCurve { .. })) {
2
} else {
0
}
}
/// The dialog's own rows (after the selection inputs); true when Enter was pressed in a value.
pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [SelInput]) -> bool {
let t = Tokens::get();
let mut enter = false;
match k {
Sm::Flange { ty, height, angle, radius, position, flip, distance, reverse, rule } => {
*ty = flange_type(inputs);
row_label(ui, "Type");
ui.label(FLANGE_TYPES.get(*ty).copied().unwrap_or(""));
ui.end_row();
match *ty {
1 => {
row_label(ui, "Height");
enter |= field(ui, height);
ui.end_row();
row_label(ui, "Angle");
enter |= field(ui, angle);
ui.end_row();
row_label(ui, "Bend Position");
combo(ui, "sm_pos", &POSITION_LABELS, position);
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();
}
2 => {
row_label(ui, "Distance");
enter |= field(ui, distance);
ui.end_row();
row_label(ui, "Other side");
ui.checkbox(reverse, "");
ui.end_row();
rule_combo(app, ui, "sm_rule", rule);
}
_ => rule_combo(app, ui, "sm_rule", rule),
}
row_label(ui, "Flip");
ui.checkbox(flip, "");
ui.end_row();
}
Sm::Hem { length, gap, flip } => {
row_label(ui, "Type");
ui.label("Flat");
ui.end_row();
row_label(ui, "Length");
enter |= field(ui, length);
ui.end_row();
row_label(ui, "Gap");
let r = ui.add(egui::TextEdit::singleline(gap).hint_text("from the rule"));
crate::params_dialog::complete(ui, &r, gap);
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::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 } => {
let rules = rule_list(app);
let mut names: Vec<String> = rules.iter().map(|r| r.name.clone()).collect();
names.push("New rule…".into());
let refs: Vec<&str> = names.iter().map(String::as_str).collect();
row_label(ui, "Rule");
combo(ui, "sm_rules", &refs, pick);
ui.end_row();
if *loaded != Some(*pick) {
// Show the picked rule (a new one starts from the one shown before).
let base = rules.get(*pick).cloned().unwrap_or_else(|| rules.first().cloned().unwrap_or_else(SheetRule::steel));
*values = vec![
base.thickness.clone(),
base.k_factor.clone(),
base.bend_radius.clone(),
base.relief_width.clone(),
base.relief_depth.clone(),
base.corner_relief.clone(),
base.hem_gap.clone(),
base.gap.clone(),
];
*active = app.session.doc.sheet_rule(None).name == base.name || *pick >= rules.len();
if *pick >= rules.len() {
*name = format!("Rule{}", rules.len() + 1);
}
*loaded = Some(*pick);
}
if *pick >= rules.len() {
row_label(ui, "Name");
ui.text_edit_singleline(name);
ui.end_row();
}
for ((_, label), v) in RULE_FIELDS.iter().zip(values.iter_mut()) {
row_label(ui, label);
enter |= field(ui, v);
ui.end_row();
}
row_label(ui, "Active rule");
ui.checkbox(active, "");
ui.end_row();
if *pick < rules.len() && !app.session.doc.sheet.rules.is_empty() {
row_label(ui, "");
if ui.button("Delete rule").clicked() {
let n = rules.get(*pick).map(|r| r.name.clone()).unwrap_or_default();
match app.run("FusionSheetMetalRulesCommand", json!({"name": n, "delete": true})) {
Ok(_) => {
*pick = 0;
*loaded = None;
}
Err(e) => app.set_status(e, true),
}
}
ui.end_row();
}
}
Sm::FlatPattern { data, key, status } => {
let body = inputs.first().and_then(|i| i.items.first()).and_then(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None });
let want = (app.session.revision, body.clone());
if key.as_ref() != Some(&want) {
let mut p = json!({});
if let Some(b) = &body {
p["body"] = json!(b);
}
*data = Some(app.session.execute("FusionSheetMetalFlatPatternCmd", &p).map_err(|e| e.to_string()));
*key = Some(want);
}
match data {
Some(Ok(v)) => {
let size = &v["flat_size_mm"];
row_label(ui, "Body");
ui.label(v["body"].as_str().unwrap_or(""));
ui.end_row();
row_label(ui, "Flat size");
ui.label(format!(
"{:.2} × {:.2} × {:.2} mm",
size[0].as_f64().unwrap_or(0.0),
size[1].as_f64().unwrap_or(0.0),
size[2].as_f64().unwrap_or(0.0)
));
ui.end_row();
let bends = v["bends"].as_array().cloned().unwrap_or_default();
row_label(ui, "Bends");
ui.label(format!("{}", bends.len()));
ui.end_row();
for b in bends.iter().take(50) {
let up = b["direction"] == "up";
let (up_col, down_col) = bend_colors();
row_label(ui, &format!(" Bend {}", b["bend"].as_u64().unwrap_or(0)));
ui.label(
RichText::new(format!(
"{} {:.1}° R{:.2}",
if up { "Up" } else { "Down" },
b["angle_deg"].as_f64().unwrap_or(0.0),
b["radius"].as_f64().unwrap_or(0.0)
))
.color(if up { up_col } else { down_col }),
);
ui.end_row();
}
row_label(ui, "");
if ui.button("Export DXF…").clicked() {
*status = Some(export_dxf(app, v["body"].as_str().unwrap_or("")));
}
ui.end_row();
if let Some(s) = status {
row_label(ui, "");
ui.add(egui::Label::new(RichText::new(s.as_str()).color(t.text_dim)).wrap());
ui.end_row();
}
}
Some(Err(e)) => {
row_label(ui, "");
ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.error)).wrap());
ui.end_row();
}
None => {}
}
}
}
enter
}
fn export_dxf(app: &mut SolveApp, body: &str) -> String {
let name = format!("{}-{body}.dxf", app.session.doc.name);
let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&name, &["dxf"])) else {
return "Export cancelled".into();
};
match app.run("sheet.export_dxf", json!({"body": body, "path": path})) {
Ok(_) => {
app.set_status(format!("Exported {path}"), false);
format!("Saved {path}")
}
Err(e) => e,
}
}
// ---- commands ----
fn rule_value(app: &SolveApp, rule: usize) -> Option<String> {
rule.checked_sub(1).and_then(|i| rule_list(app).get(i).map(|r| r.name.clone()))
}
fn items(inputs: &[SelInput], i: usize) -> &[Sel] {
inputs.get(i).map(|x| x.items.as_slice()).unwrap_or(&[])
}
fn pt(v: Vec3) -> Value {
json!([v.x, v.y, v.z])
}
fn edge_points(inputs: &[SelInput]) -> Vec<Value> {
items(inputs, 0).iter().filter_map(|x| if let Sel::Edge { point, .. } = x { Some(pt(*point)) } else { None }).collect()
}
/// The commands the dialog's OK runs (`extra`: parameters kept from the feature being edited).
pub fn commands(app: &SolveApp, k: &Sm, inputs: &[SelInput], extra: &Map<String, Value>) -> Result<Vec<(String, Value)>, String> {
let s = &app.session;
let (cmd, mut p): (&str, Value) = match k {
Sm::Flange { height, angle, radius, position, flip, distance, reverse, rule, .. } => {
let mut p = match flange_type(inputs) {
1 => {
let mut p = json!({"type": "edge", "edges": edge_points(inputs), "height": height, "angle": angle,
"position": POSITIONS.get(*position).copied().unwrap_or("inside")});
if !radius.trim().is_empty() {
p["radius"] = json!(radius);
}
p
}
2 => {
let ids: Vec<String> =
items(inputs, 0).iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect();
let st = s.model.state();
let sketch = st
.sketches
.iter()
.rev()
.find(|ss| ids.iter().all(|id| ss.sketch.curve_index(id).is_some()))
.map(|ss| ss.feature)
.ok_or("pick lines of one sketch")?;
json!({"type": "contour", "sketch": sketch, "curves": ids, "distance": distance, "reverse": reverse})
}
_ => {
let ps: Vec<(u64, usize)> = items(inputs, 0)
.iter()
.filter_map(|x| if let Sel::Profile { sketch, index } = x { Some((*sketch, *index)) } else { None })
.collect();
let sketch = ps.first().map(|x| x.0).ok_or("select a profile, edges or sketch lines first")?;
let idx: Vec<usize> = ps.iter().filter(|x| x.0 == sketch).map(|x| x.1).collect();
json!({"type": "base", "sketch": sketch, "profiles": idx})
}
};
p["flip"] = json!(flip);
if let Some(r) = rule_value(app, *rule).filter(|_| p["type"] != "edge") {
p["rule"] = json!(r);
}
("FusionSheetMetalFlangeCommand", p)
}
Sm::Hem { length, gap, flip } => {
let edges = edge_points(inputs);
if edges.is_empty() {
return Err("select sheet edges first".into());
}
let mut p = json!({"edges": edges, "length": length, "flip": flip});
if !gap.trim().is_empty() {
p["gap"] = json!(gap);
}
("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() {
p["body"] = json!(name);
}
(if *refold { "sheet.refold" } else { "FusionSheetmetalUnfoldCommand" }, p)
}
Sm::Convert { rule } => {
let Some(Sel::Face { body, point, .. }) = items(inputs, 0).first() else { return Err("select the plate's large face first".into()) };
let mut p = json!({"body": body, "face": pt(*point)});
if let Some(r) = rule_value(app, *rule) {
p["rule"] = json!(r);
}
("ConvertToSheetMetalCmd", p)
}
Sm::Rules { pick, name, values, active, .. } => {
let rules = rule_list(app);
let n = match rules.get(*pick) {
Some(r) => r.name.clone(),
None => name.trim().to_string(),
};
if n.is_empty() {
return Err("give the rule a name".into());
}
let mut p = json!({"name": n, "active": active});
for ((k, _), v) in RULE_FIELDS.iter().zip(values) {
if !v.trim().is_empty() {
p[*k] = json!(v);
}
}
("FusionSheetMetalRulesCommand", p)
}
Sm::FlatPattern { .. } => return Ok(Vec::new()),
};
if let Value::Object(m) = &mut p {
for (k, v) in extra {
m.entry(k.clone()).or_insert_with(|| v.clone());
}
}
Ok(vec![(cmd.to_string(), p)])
}
/// The dialog editing a sheet metal feature, filled from it.
pub fn for_feature(app: &SolveApp, kind: &FeatureKind) -> Option<(Kind, Vec<SelInput>, Map<String, Value>)> {
let s = &app.session;
let st = s.model.state();
let rule_index = |r: &Option<String>| r.as_ref().and_then(|n| rule_list(app).iter().position(|x| &x.name == n)).map_or(0, |i| i + 1);
let mut extra = Map::new();
let (k, items): (Sm, Vec<Sel>) = match kind {
FeatureKind::SheetBase { sketch, profiles, rule, flip } => {
let ps = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();
let mut f = new_flange(0);
if let Sm::Flange { flip: fl, rule: r, .. } = &mut f {
*fl = *flip;
*r = rule_index(rule);
}
(f, ps.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect())
}
FeatureKind::SheetContour { curves, distance, rule, flip, reverse, .. } => {
let mut f = new_flange(2);
if let Sm::Flange { flip: fl, rule: r, distance: d, reverse: rv, .. } = &mut f {
*fl = *flip;
*r = rule_index(rule);
*d = distance.clone();
*rv = *reverse;
}
(f, curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect())
}
FeatureKind::SheetFlange { edges, height, angle, radius, position, flip, body } => {
if let Some(b) = body {
extra.insert("body".into(), json!(b));
}
let f = Sm::Flange {
ty: 1,
height: height.clone(),
angle: angle.clone(),
radius: radius.clone().unwrap_or_default(),
position: POSITIONS.iter().position(|p| p == position).unwrap_or(0),
flip: *flip,
distance: "20 mm".into(),
reverse: false,
rule: 0,
};
(f, edges.iter().filter_map(|p| edge_sel(s, *p)).collect())
}
FeatureKind::SheetHem { edges, length, gap, flip, body } => {
if let Some(b) = body {
extra.insert("body".into(), json!(b));
}
(
Sm::Hem { length: length.clone(), gap: gap.clone().unwrap_or_default(), flip: *flip },
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,
};
let id = match &k {
Sm::Flange { .. } => "FusionSheetMetalFlangeCommand",
Sm::Hem { .. } => "FusionSheetMetalHemFlangeCommand",
Sm::Unfold { refold: false } => "FusionSheetmetalUnfoldCommand",
Sm::Unfold { .. } => "sheet.refold",
_ => "ConvertToSheetMetalCmd",
};
let (_, mut inputs) = start(app, id)?;
if let Some(i) = inputs.first_mut() {
i.items = items;
}
// Profiles stay a profile selection (`All` keeps them all).
if let (FeatureKind::SheetBase { profiles: ProfileSel::All, .. }, Some(i)) = (kind, inputs.first())
&& i.items.is_empty()
{
extra.insert("profiles".into(), json!("all"));
}
Some((Kind::Sheet(k), inputs, extra))
}
// ---- view ----
/// The direction an edge flange or hem grows from its first edge: out of the sheet's larger
/// neighbouring face (flipped with Flip).
pub fn arrow(app: &SolveApp, d: &Dialog) -> Option<Vec3> {
let Kind::Sheet(k) = &d.kind else { return None };
let flip = match k {
Sm::Flange { ty: 1, flip, .. } | Sm::Hem { flip, .. } => *flip,
_ => return None,
};
let Sel::Edge { body, index, point } = d.inputs.first()?.items.first()? else { return None };
let st = app.session.model.state();
let m = st.body(body)?.mesh();
let faces = m.edge_faces.get(*index)?;
let area = |f: u32| -> f64 {
m.triangles
.iter()
.zip(&m.tri_face)
.filter(|(_, tf)| **tf == f)
.filter_map(|(t, _)| m.tri(t))
.map(|[a, b, c]| (b - a).cross(c - a).len() * 0.5)
.sum()
};
let f = faces.iter().copied().max_by(|a, b| area(*a).total_cmp(&area(*b)))?;
let n = m
.triangles
.iter()
.zip(&m.tri_face)
.filter(|(_, tf)| **tf == f)
.filter_map(|(t, _)| m.tri(t))
.min_by(|a, b| ((a[0] + a[1] + a[2]) / 3.0).dist(*point).total_cmp(&((b[0] + b[1] + b[2]) / 3.0).dist(*point)))
.and_then(|[a, b, c]| (b - a).cross(c - a).normalized())?;
Some(if flip { -n } else { n })
}
/// Bend line colours: up and down (legible on either theme).
fn bend_colors() -> (Color32, Color32) {
if crate::theme::is_dark() {
(Color32::from_rgb(110, 200, 120), Color32::from_rgb(240, 150, 80))
} else {
(Color32::from_rgb(30, 140, 50), Color32::from_rgb(200, 90, 20))
}
}
/// The flat pattern view: the outline, cut-outs and bend lines (up and down in their colours)
/// drawn flat over the viewport, fitted to it. True when the dialog is a flat pattern.
pub fn overlay(painter: &egui::Painter, rect: Rect, d: &Dialog) -> bool {
let Kind::Sheet(Sm::FlatPattern { data, .. }) = &d.kind else { return false };
let t = Tokens::get();
let (up_col, down_col) = bend_colors();
painter.rect_filled(rect, 0.0, t.panel);
let Some(Ok(v)) = data else {
painter.text(rect.center(), Align2::CENTER_CENTER, "no sheet metal body", FontId::proportional(14.0), t.text_dim);
return true;
};
let read = |p: &Value| -> Option<(f64, f64)> { Some((p.get(0)?.as_f64()?, p.get(1)?.as_f64()?)) };
let loops: Vec<Vec<(f64, f64)>> =
v["outline"].as_array().into_iter().flatten().map(|l| l.as_array().into_iter().flatten().filter_map(read).collect()).collect();
let holes: Vec<Vec<(f64, f64)>> =
v["cutouts"].as_array().into_iter().flatten().map(|l| l.as_array().into_iter().flatten().filter_map(read).collect()).collect();
let mut lo = (f64::INFINITY, f64::INFINITY);
let mut hi = (f64::NEG_INFINITY, f64::NEG_INFINITY);
for p in loops.iter().flatten() {
lo = (lo.0.min(p.0), lo.1.min(p.1));
hi = (hi.0.max(p.0), hi.1.max(p.1));
}
if !(lo.0.is_finite() && hi.0 > lo.0 && hi.1 > lo.1) {
return true;
}
// Fit with a margin, leaving the dialog's side free.
let area = Rect::from_min_max(rect.min + vec2(40.0, 60.0), rect.max - vec2(320.0, 60.0));
let area = if area.width() < 100.0 { rect.shrink(40.0) } else { area };
let scale = (f64::from(area.width()) / (hi.0 - lo.0)).min(f64::from(area.height()) / (hi.1 - lo.1));
let ox = f64::from(area.center().x) - (lo.0 + hi.0) * 0.5 * scale;
let oy = f64::from(area.center().y) + (lo.1 + hi.1) * 0.5 * scale;
let to = |p: (f64, f64)| -> Pos2 { pos2((ox + p.0 * scale) as f32, (oy - p.1 * scale) as f32) };
for l in &loops {
let pts: Vec<Pos2> = l.iter().map(|p| to(*p)).collect();
if pts.len() >= 3 {
painter.add(egui::Shape::closed_line(pts, Stroke::new(1.8, t.text)));
}
}
for h in &holes {
let pts: Vec<Pos2> = h.iter().map(|p| to(*p)).collect();
if pts.len() >= 2 {
painter.add(egui::Shape::closed_line(pts, Stroke::new(1.4, t.text)));
}
}
for b in v["bends"].as_array().into_iter().flatten() {
let (Some(a), Some(e)) = (read(&b["start"]), read(&b["end"])) else { continue };
let col = if b["direction"] == "up" { up_col } else { down_col };
painter.extend(egui::Shape::dashed_line(&[to(a), to(e)], Stroke::new(1.6, col), 8.0, 5.0));
}
// Legend.
let mut y = rect.top() + 14.0;
painter.text(pos2(rect.left() + 14.0, y), Align2::LEFT_TOP, "FLAT PATTERN", FontId::proportional(13.0), t.text);
for (label, col) in [("Bend up", up_col), ("Bend down", down_col)] {
y += 20.0;
painter.line_segment([pos2(rect.left() + 14.0, y + 7.0), pos2(rect.left() + 38.0, y + 7.0)], Stroke::new(2.0, col));
painter.text(pos2(rect.left() + 44.0, y), Align2::LEFT_TOP, label, FontId::proportional(12.0), t.text_dim);
}
true
}
#[cfg(test)]
#[path = "dialogs_sheet_tests.rs"]
mod tests;