//! Plastic dialogs: Boss, Lip / Groove, Snap Fit and Rest (placed by a pick on a face), Manage //! Plastic Rules and Assign Plastic Rule. Values left empty take the body's plastic rule. use serde_json::{Map, Value, json}; use solvecraft_engine::Sel; use solvecraft_engine::doc::FeatureKind; use solvecraft_engine::doc::expr::Kind as ValueKind; use solvecraft_engine::doc::plastic::PlasticRule; use solvecraft_engine::geom::Vec3; use crate::SolveApp; use crate::dialogs::{Kind, combo, face_sel, field, row_label}; use crate::selection::{BODIES, FACES, PLANAR_FACES, SelInput}; #[derive(Clone, Debug)] pub enum Pl { Boss { diameter: String, height: String, hole_diameter: String, hole_depth: String, draft: String, fillet: String, ribs: String, rib_thickness: String, rib_length: String, rib_offset: String, }, Lip { groove: bool, width: String, height: String, gap: String, outside: bool, }, SnapFit { hook: usize, length: String, thickness: String, width: String, catch_depth: String, catch_length: String, }, Rest { round: bool, width: String, length: String, height: String, draft: String, thickness: String, }, /// The rule manager: the rule shown (index into the rules, past the end for a new one). Rules { pick: usize, name: String, values: Vec, active: bool, loaded: Option, }, Assign { rule: usize, }, } impl Pl { pub fn title(&self) -> &'static str { match self { Pl::Boss { .. } => "BOSS", Pl::Lip { groove: false, .. } => "LIP", Pl::Lip { .. } => "GROOVE", Pl::SnapFit { .. } => "SNAP FIT", Pl::Rest { .. } => "REST", Pl::Rules { .. } => "MANAGE PLASTIC RULES", Pl::Assign { .. } => "ASSIGN PLASTIC RULE", } } pub fn previews(&self) -> bool { !matches!(self, Pl::Rules { .. } | Pl::Assign { .. }) } pub fn primary(&mut self) -> Option<(&'static str, ValueKind, &mut String)> { Some(match self { Pl::Boss { height, .. } | Pl::Lip { height, .. } | Pl::Rest { height, .. } => ("Height", ValueKind::Length, height), Pl::SnapFit { length, .. } => ("Length", ValueKind::Length, length), _ => return None, }) } } fn boss() -> Pl { Pl::Boss { diameter: "6 mm".into(), height: "10 mm".into(), hole_diameter: "3 mm".into(), hole_depth: String::new(), draft: String::new(), fillet: String::new(), ribs: "0".into(), rib_thickness: "1 mm".into(), rib_length: "3 mm".into(), rib_offset: "1 mm".into(), } } fn snap_fit() -> Pl { Pl::SnapFit { hook: 0, length: "10 mm".into(), thickness: "1.5 mm".into(), width: "5 mm".into(), catch_depth: "1 mm".into(), catch_length: "2 mm".into(), } } fn rest() -> Pl { Pl::Rest { round: true, width: "10 mm".into(), length: "15 mm".into(), height: "2 mm".into(), draft: String::new(), thickness: String::new() } } fn lip(groove: bool) -> Pl { Pl::Lip { groove, width: "1.5 mm".into(), height: "2 mm".into(), gap: String::new(), outside: false } } /// The dialog for a plastic command. pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { let (pl, inputs) = match id { "plastic.boss" => (boss(), vec![SelInput::new("Position", PLANAR_FACES, false)]), "plastic.lip" => (lip(false), vec![SelInput::new("Rim face", FACES, false)]), "plastic.snap_fit" => (snap_fit(), vec![SelInput::new("Position", PLANAR_FACES, false)]), "plastic.rest" => (rest(), vec![SelInput::new("Position", PLANAR_FACES, false)]), "plastic.manage_rules" => { let rules = app.session.doc.plastic_rules(); let pick = app.session.doc.plastic.active.as_ref().and_then(|a| rules.iter().position(|r| &r.name == a)).unwrap_or(0); (Pl::Rules { pick, name: String::new(), values: Vec::new(), active: false, loaded: None }, vec![]) } "plastic.assign_rule" => (Pl::Assign { rule: 1 }, vec![SelInput::new("Bodies", BODIES, true)]), _ => return None, }; Some((Kind::Plastic(pl), inputs)) } /// A value row whose empty value means "from the rule" (or another default). fn opt_field(ui: &mut egui::Ui, label: &str, v: &mut String, hint: &str) -> bool { row_label(ui, label); let r = ui.add(egui::TextEdit::singleline(v).hint_text(hint)); crate::params_dialog::complete(ui, &r, v); ui.end_row(); r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) } fn value_row(ui: &mut egui::Ui, label: &str, v: &mut String) -> bool { row_label(ui, label); let e = field(ui, v); ui.end_row(); e } /// Directions across a face (with normal `n`) a snap fit's catch can point: two axes and their /// opposites, named after the world axis they run along. pub fn hook_dirs(n: Vec3) -> Vec<(String, Vec3)> { let u = [Vec3::X, Vec3::Y, Vec3::Z].into_iter().filter_map(|a| (a - n * a.dot(n)).normalized()).next().unwrap_or_else(|| n.any_perp()); let v = n.cross(u); let name = |d: Vec3| -> String { let (axis, c) = [("X", d.x), ("Y", d.y), ("Z", d.z)].into_iter().max_by(|a, b| a.1.abs().total_cmp(&b.1.abs())).unwrap_or(("X", 0.0)); format!("{}{axis}", if c < 0.0 { "−" } else { "+" }) }; [u, -u, v, -v].into_iter().map(|d| (name(d), d)).collect() } fn face_normal(app: &SolveApp, inputs: &[SelInput]) -> Option { match inputs.first()?.items.first()? { Sel::Face { body, index, .. } => crate::dialogs::planar_face(&app.session, body, *index).map(|(_, n)| n), _ => None, } } /// 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 Pl, inputs: &[SelInput]) -> bool { let mut enter = false; match k { Pl::Boss { diameter, height, hole_diameter, hole_depth, draft, fillet, ribs, rib_thickness, rib_length, rib_offset } => { enter |= value_row(ui, "Diameter", diameter); enter |= value_row(ui, "Height", height); enter |= opt_field(ui, "Hole Diameter", hole_diameter, "no hole"); enter |= opt_field(ui, "Hole Depth", hole_depth, "to the face"); enter |= opt_field(ui, "Draft Angle", draft, "from the rule"); enter |= opt_field(ui, "Root Fillet", fillet, "none"); enter |= value_row(ui, "Ribs", ribs); if app.session.doc.eval(ribs, ValueKind::Unitless).is_ok_and(|n| n >= 1.0) { enter |= value_row(ui, " Rib Thickness", rib_thickness); enter |= value_row(ui, " Rib Length", rib_length); enter |= value_row(ui, " Rib Offset", rib_offset); } } Pl::Lip { groove, width, height, gap, outside } => { row_label(ui, "Type"); let mut ty = usize::from(*groove); combo(ui, "pl_lip", &["Lip", "Groove"], &mut ty); *groove = ty == 1; ui.end_row(); row_label(ui, "Rim Edge"); let mut side = usize::from(*outside); combo(ui, "pl_side", &["Inside", "Outside"], &mut side); *outside = side == 1; ui.end_row(); enter |= value_row(ui, "Width", width); enter |= value_row(ui, "Height", height); if *groove { enter |= opt_field(ui, "Clearance", gap, "from the rule"); } } Pl::SnapFit { hook, length, thickness, width, catch_depth, catch_length } => { let dirs = face_normal(app, inputs).map(hook_dirs).unwrap_or_default(); if !dirs.is_empty() { let names: Vec<&str> = dirs.iter().map(|(n, _)| n.as_str()).collect(); row_label(ui, "Catch Direction"); combo(ui, "pl_hook", &names, hook); ui.end_row(); } enter |= value_row(ui, "Length", length); enter |= value_row(ui, "Thickness", thickness); enter |= value_row(ui, "Width", width); enter |= value_row(ui, "Catch Depth", catch_depth); enter |= value_row(ui, "Catch Length", catch_length); } Pl::Rest { round, width, length, height, draft, thickness } => { row_label(ui, "Shape"); let mut sh = usize::from(!*round); combo(ui, "pl_rest", &["Round", "Rectangular"], &mut sh); *round = sh == 0; ui.end_row(); enter |= value_row(ui, if *round { "Diameter" } else { "Width" }, width); if !*round { enter |= value_row(ui, "Length", length); } enter |= value_row(ui, "Height", height); enter |= opt_field(ui, "Draft Angle", draft, "from the rule"); enter |= opt_field(ui, "Wall Thickness", thickness, "solid"); } Pl::Rules { pick, name, values, active, loaded } => { let rules = app.session.doc.plastic_rules(); let mut names: Vec = 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, "pl_rules", &refs, pick); ui.end_row(); if *loaded != Some(*pick) { let base = rules.get(*pick).or(rules.first()).cloned(); if let Some(b) = base { *values = std::iter::once(b.material.clone()).chain(b.fields().iter().map(|(_, e, _)| (*e).clone())).collect(); *active = app.session.doc.plastic.active.as_deref() == Some(b.name.as_str()) && *pick < rules.len(); } if *pick >= rules.len() { *name = format!("Plastic Rule{}", app.session.doc.plastic.rules.len() + 1); } *loaded = Some(*pick); } if *pick >= rules.len() { row_label(ui, "Name"); ui.text_edit_singleline(name); ui.end_row(); } let keys = rule_keys(); for (i, v) in values.iter_mut().enumerate() { let label = if i == 0 { "Material".to_string() } else { keys.get(i - 1).map(|k| label_of(k)).unwrap_or_default() }; row_label(ui, &label); enter |= if i == 0 { ui.text_edit_singleline(v).lost_focus() && ui.input(|x| x.key_pressed(egui::Key::Enter)) } else { field(ui, v) }; ui.end_row(); } row_label(ui, "Active rule"); ui.checkbox(active, ""); ui.end_row(); } Pl::Assign { rule } => { let mut names: Vec = vec!["(no rule)".into()]; names.extend(app.session.doc.plastic_rules().into_iter().map(|r| r.name)); let refs: Vec<&str> = names.iter().map(String::as_str).collect(); row_label(ui, "Rule"); combo(ui, "pl_assign", &refs, rule); ui.end_row(); } } enter } /// The rule's value fields, in the order `PlasticRule::fields` gives them. fn rule_keys() -> Vec<&'static str> { PlasticRule::library().first().map(|r| r.fields().iter().map(|(k, _, _)| *k).collect()).unwrap_or_default() } fn label_of(key: &str) -> String { key.split('_') .map(|w| { let mut c = w.chars(); c.next().map(|f| f.to_uppercase().collect::() + c.as_str()).unwrap_or_default() }) .collect::>() .join(" ") } fn pt(v: Vec3) -> Value { json!([v.x, v.y, v.z]) } /// Put non-empty optional values into the parameters. fn put(p: &mut Value, list: &[(&str, &String)]) { for (k, v) in list { if !v.trim().is_empty() { p[*k] = json!(v); } } } /// The commands the dialog's OK runs (`extra`: parameters kept from the feature being edited). pub fn commands(app: &SolveApp, k: &Pl, inputs: &[SelInput], extra: &Map) -> Result, String> { let face = inputs .first() .and_then(|i| i.items.first()) .and_then(|x| if let Sel::Face { body, point, .. } = x { Some((body.clone(), *point)) } else { None }); let placed = |what: &str| -> Result { let (body, at) = face.clone().ok_or_else(|| format!("select {what} first"))?; Ok(json!({"position": pt(at), "body": body})) }; let (cmd, mut p): (&str, Value) = match k { Pl::Boss { diameter, height, hole_diameter, hole_depth, draft, fillet, ribs, rib_thickness, rib_length, rib_offset } => { let mut p = placed("a point on a face")?; p["diameter"] = json!(diameter); p["height"] = json!(height); put(&mut p, &[("hole_diameter", hole_diameter), ("hole_depth", hole_depth), ("draft", draft), ("fillet", fillet)]); if app.session.doc.eval(ribs, ValueKind::Unitless).is_ok_and(|n| n >= 1.0) { put(&mut p, &[("ribs", ribs), ("rib_thickness", rib_thickness), ("rib_length", rib_length), ("rib_offset", rib_offset)]); } ("plastic.boss", p) } Pl::Lip { groove, width, height, gap, outside } => { let (body, at) = face.clone().ok_or("select the rim face first")?; let mut p = json!({"face": pt(at), "body": body, "width": width, "height": height, "type": if *groove { "groove" } else { "lip" }, "side": if *outside { "outside" } else { "inside" }}); if *groove { put(&mut p, &[("gap", gap)]); } ("plastic.lip", p) } Pl::SnapFit { hook, length, thickness, width, catch_depth, catch_length } => { let mut p = placed("a point on a face")?; let n = extra .get("direction") .and_then(|v| Some(Vec3::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?, v.get(2)?.as_f64()?))) .or(face_normal(app, inputs)); let dir = n.map(hook_dirs).and_then(|d| d.get(*hook).map(|x| x.1)).ok_or("pick a planar face")?; p["hook"] = pt(dir); for (key, v) in [("length", length), ("thickness", thickness), ("width", width), ("catch_depth", catch_depth), ("catch_length", catch_length)] { p[key] = json!(v); } ("plastic.snap_fit", p) } Pl::Rest { round, width, length, height, draft, thickness } => { let mut p = placed("a point on a face")?; p["width"] = json!(width); p["height"] = json!(height); if !*round { p["length"] = json!(length); } put(&mut p, &[("draft", draft), ("thickness", thickness)]); ("plastic.rest", p) } Pl::Rules { pick, name, values, active, .. } => { let rules = app.session.doc.plastic_rules(); 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}); if let Some(m) = values.first().filter(|m| !m.trim().is_empty()) { p["material"] = json!(m); } for (key, v) in rule_keys().iter().zip(values.iter().skip(1)) { if !v.trim().is_empty() { p[*key] = json!(v); } } ("plastic.manage_rules", p) } Pl::Assign { rule } => { let bodies: Vec = inputs .first() .into_iter() .flat_map(|i| &i.items) .filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }) .collect(); if bodies.is_empty() { return Err("select bodies first".into()); } let name = rule.checked_sub(1).and_then(|i| app.session.doc.plastic_rules().get(i).map(|r| r.name.clone())).unwrap_or_default(); ("plastic.assign_rule", json!({"bodies": bodies, "rule": name})) } }; if let Value::Object(m) = &mut p { for (key, v) in extra { m.entry(key.clone()).or_insert_with(|| v.clone()); } } Ok(vec![(cmd.to_string(), p)]) } fn s(o: &Option) -> String { o.clone().unwrap_or_default() } /// The dialog editing a plastic feature, filled from it. pub fn for_feature(app: &SolveApp, kind: &FeatureKind) -> Option<(Kind, Vec, Map)> { let ss = &app.session; let mut extra = Map::new(); let (pl, id, at): (Pl, &str, Vec3) = match kind { FeatureKind::Boss { position, direction, diameter, height, hole_diameter, hole_depth, draft, fillet, ribs, rib_thickness, rib_length, rib_offset, body, } => { extra.insert("direction".into(), pt(*direction)); if let Some(b) = body { extra.insert("body".into(), json!(b)); } let mut b = boss(); if let Pl::Boss { diameter: d, height: h, hole_diameter: hd, hole_depth: hp, draft: dr, fillet: f, ribs: r, rib_thickness: rt, rib_length: rl, rib_offset: ro, } = &mut b { *d = diameter.clone(); *h = height.clone(); *hd = s(hole_diameter); *hp = s(hole_depth); *dr = s(draft); *f = s(fillet); *r = ribs.clone().unwrap_or_else(|| "0".into()); for (slot, v) in [(rt, rib_thickness), (rl, rib_length), (ro, rib_offset)] { if let Some(v) = v { *slot = v.clone(); } } } (b, "plastic.boss", *position) } FeatureKind::Lip { face, width, height, groove, gap, outside, body } => { if let Some(b) = body { extra.insert("body".into(), json!(b)); } (Pl::Lip { groove: *groove, width: width.clone(), height: height.clone(), gap: s(gap), outside: *outside }, "plastic.lip", *face) } FeatureKind::SnapFit { position, direction, hook, length, thickness, width, catch_depth, catch_length, body } => { extra.insert("direction".into(), pt(*direction)); if let Some(b) = body { extra.insert("body".into(), json!(b)); } let h = hook_dirs(*direction).iter().position(|(_, d)| d.dist(*hook) < 1e-6); if h.is_none() { extra.insert("hook".into(), pt(*hook)); } let f = Pl::SnapFit { hook: h.unwrap_or(0), length: length.clone(), thickness: thickness.clone(), width: width.clone(), catch_depth: catch_depth.clone(), catch_length: catch_length.clone(), }; (f, "plastic.snap_fit", *position) } FeatureKind::Rest { position, direction, along, width, length, height, draft, thickness, body } => { extra.insert("direction".into(), pt(*direction)); if let Some(a) = along { extra.insert("along".into(), pt(*a)); } if let Some(b) = body { extra.insert("body".into(), json!(b)); } let r = Pl::Rest { round: length.is_none(), width: width.clone(), length: length.clone().unwrap_or_else(|| "15 mm".into()), height: height.clone(), draft: s(draft), thickness: s(thickness), }; (r, "plastic.rest", *position) } _ => return None, }; let (_, mut inputs) = start(app, id)?; if let Some(i) = inputs.first_mut() { i.items = face_sel(ss, at).into_iter().collect(); } // A pick that no longer finds its face keeps the stored point. if let Some(i) = inputs.first_mut().filter(|i| i.items.is_empty()) { i.items = vec![Sel::Face { body: extra.get("body").and_then(Value::as_str).unwrap_or_default().to_string(), index: 0, point: at }]; } Some((Kind::Plastic(pl), inputs, extra)) } #[cfg(test)] #[path = "dialogs_plastic_tests.rs"] mod tests;