diff --git a/crates/engine/src/frames.rs b/crates/engine/src/frames.rs index 3302c401..8f68628d 100644 --- a/crates/engine/src/frames.rs +++ b/crates/engine/src/frames.rs @@ -64,6 +64,7 @@ fn table(id: &str) -> &'static [(&'static str, K)] { "FusionCurvatureCombAnalysisCommand" | "FusionMinimumRadiusAnalysisCommand" | "FusionIsoCurveAnalysisCommand" => { &[("edges", Picks), ("faces", Picks)] } + "parts.insert" | "FusionFastenersCommand" => &[("at", Pick), ("point", Make), ("direction", Dir)], "AppearanceCommand" => &[("faces", Picks), ("bodies", Body), ("body", Body)], "FusionMoveCommand" => &[("bodies", Body), ("translate", Dir), ("axis", Dir), ("origin", Make)], "FusionCombineCommand" => &[("target", Body), ("tools", Body)], diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5e241b41..13284657 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -215,6 +215,8 @@ pub enum Kind { Appearance(crate::dialogs_appearance::Ap), /// Contact sets, motion studies, exploded views, configurations (`dialogs_motion`). Motion(crate::dialogs_motion::Mo), + /// Insert Part from the standard parts library (`dialogs_parts`). + Part(crate::dialogs_parts::Pt), } /// The rest of the Hole dialog: placement, extents, tap type and drill point. @@ -413,7 +415,8 @@ impl Dialog { .or_else(|| crate::dialogs_sheet::start(app, id)) .or_else(|| crate::dialogs_plastic::start(app, id)) .or_else(|| crate::dialogs_appearance::start(app, id)) - .or_else(|| crate::dialogs_motion::start(app, id))?; + .or_else(|| crate::dialogs_motion::start(app, id)) + .or_else(|| crate::dialogs_parts::start(app, id))?; Dialog::new(kind, inputs) } }; @@ -514,6 +517,7 @@ impl Dialog { Kind::Assembly(k) => return k.previews(), Kind::Sheet(k) => return k.previews(), Kind::Plastic(k) => return k.previews(), + Kind::Part(_) => return true, _ => {} } matches!( @@ -773,6 +777,7 @@ fn title(k: &Kind) -> &'static str { Kind::Plastic(k) => k.title(), Kind::Appearance(_) => "APPEARANCE", Kind::Motion(k) => k.title(), + Kind::Part(_) => "INSERT PART", } } @@ -1493,6 +1498,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { Kind::Plastic(k) => enter |= crate::dialogs_plastic::rows(app, ui, k, &d.inputs), Kind::Appearance(k) => enter |= crate::dialogs_appearance::rows(app, ui, k, &mut d.inputs), Kind::Motion(k) => enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs), + Kind::Part(k) => enter |= crate::dialogs_parts::rows(app, ui, k, &d.inputs), Kind::ConfirmDelete { with, fail, .. } => { if !with.is_empty() { row_label(ui, "Also deletes"); @@ -2127,6 +2133,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S Kind::Plastic(k) => return crate::dialogs_plastic::commands(app, k, &d.inputs, &d.extra), Kind::Appearance(k) => return Ok(crate::dialogs_appearance::commands(k, &d.inputs)), Kind::Motion(k) => return crate::dialogs_motion::commands(app, k, &d.inputs), + Kind::Part(k) => return crate::dialogs_parts::commands(app, k, &d.inputs), }; let mut params = params; if let Value::Object(m) = &mut params { diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 7b12e855..00088733 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -26,7 +26,16 @@ const TYPE_LABELS: [&str; 7] = ["Rigid", "Revolute", "Slider", "Cylindrical", "P /// What a joint origin input accepts (snaps on faces, edges and vertices). const SNAPS: u16 = FACES | EDGES | VERTICES; /// Commands whose effect is where occurrences sit (previewed on the placed model). -const MOVES: [&str; 6] = ["JointAssembleCmdNew", "JointAsBuiltCmd", "FusionMoveJointsCommand", "joint.edit", "joint.limits", "RigidGroupCmd"]; +const MOVES: [&str; 8] = [ + "JointAssembleCmdNew", + "JointAsBuiltCmd", + "FusionMoveJointsCommand", + "joint.edit", + "joint.limits", + "RigidGroupCmd", + "parts.insert", + "FusionFastenersCommand", +]; #[derive(Clone, Debug)] pub enum Asm { diff --git a/crates/ui-egui/src/dialogs_parts.rs b/crates/ui-egui/src/dialogs_parts.rs new file mode 100644 index 00000000..a88485f6 --- /dev/null +++ b/crates/ui-egui/src/dialogs_parts.rs @@ -0,0 +1,251 @@ +//! Insert Part: the standard parts library (screws, bolts, nuts, washers, set screws, pins, +//! bearings) by family, size and length, with the size's table values and a picture of the part. +//! Picking a hole (its wall or rim) seats the part in it with a rigid joint; without one the part +//! goes at the origin. The live preview shows it placed. + +use egui::{RichText, vec2}; +use serde_json::{Value, json}; +use solvecraft_engine::{Sel, Session}; + +use crate::SolveApp; +use crate::dialogs::{Kind, combo, row_label}; +use crate::selection::{EDGES, FACES, SelInput}; +use crate::theme::Tokens; + +/// One family of the library. +#[derive(Clone, Debug, PartialEq)] +pub struct Family { + pub id: String, + pub name: String, + pub standard: String, + /// Size names and their table rows. + pub sizes: Vec<(String, Value)>, + pub lengths: Option>, +} + +/// The library as `parts.library` gives it. +pub fn families(v: &Value) -> Vec { + v.get("families") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|f| { + let id = f.get("family")?.as_str()?.to_string(); + let sizes = f + .get("sizes") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|s| Some((s.get("size")?.as_str().map(str::to_string).or_else(|| s.get("size").map(Value::to_string))?, s.clone()))) + .collect(); + Some(Family { + name: f.get("name").and_then(Value::as_str).unwrap_or(&id).to_string(), + standard: f.get("standard").and_then(Value::as_str).unwrap_or("").to_string(), + sizes, + lengths: f.get("lengths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect()), + id, + }) + }) + .collect() +} + +#[derive(Clone, Debug)] +pub struct Pt { + pub family: usize, + pub size: usize, + /// Index into the family's lengths (none: the default length). + pub length: Option, + pub name: String, + /// The (family, size, length) the picture shows (the texture is kept in egui's memory). + pub picture: Option<(usize, usize, Option)>, + /// The library, read once. + pub lib: Option>, +} + +/// The dialog for Insert Part. +pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { + matches!(id, "FusionFastenersCommand" | "parts.insert").then(|| { + ( + Kind::Part(Pt { family: 0, size: 0, length: None, name: String::new(), picture: None, lib: None }), + vec![SelInput::new("Hole (optional)", FACES | EDGES, false)], + ) + }) +} + +fn library(app: &mut SolveApp) -> Vec { + app.session.execute("parts.library", &json!({})).map(|v| families(&v)).unwrap_or_default() +} + +/// The insert command's parameters for the picks. +pub fn insert_params(lib: &[Family], k: &Pt, hole: Option<&Sel>) -> Result { + let f = lib.get(k.family).ok_or("the parts library is empty")?; + let (size, _) = f.sizes.get(k.size).ok_or("pick a size")?; + let mut p = json!({"family": f.id, "size": size}); + if let Some(l) = k.length.and_then(|i| f.lengths.as_ref().and_then(|ls| ls.get(i))) { + p["length"] = json!(l); + } + match hole { + Some(Sel::Face { point, .. } | Sel::Edge { point, .. }) => p["at"] = json!([point.x, point.y, point.z]), + _ => { + p["point"] = json!([0.0, 0.0, 0.0]); + p["direction"] = json!([0.0, 0.0, 1.0]); + } + } + if !k.name.trim().is_empty() { + p["name"] = json!(k.name.trim()); + } + Ok(p) +} + +/// A picture of the part alone. +fn picture(p: &Value) -> Option { + let mut s = Session::default(); + let mut q = p.clone(); + if let Value::Object(m) = &mut q { + m.remove("at"); + m.insert("point".into(), json!([0.0, 0.0, 0.0])); + m.insert("direction".into(), json!([0.0, 0.0, 1.0])); + } + s.execute("parts.insert", &q).ok()?; + crate::home::thumbnail(&s) +} + +/// The dialog's rows. +pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Pt, inputs: &[SelInput]) -> bool { + let t = Tokens::get(); + let lib = match &k.lib { + Some(l) => l.clone(), + None => { + let l = library(app); + k.lib = Some(l.clone()); + l + } + }; + if lib.is_empty() { + row_label(ui, ""); + ui.label(RichText::new("the parts library is not available").color(t.text_dim)); + ui.end_row(); + return false; + } + let names: Vec = + lib.iter().map(|f| if f.standard.is_empty() { f.name.clone() } else { format!("{} ({})", f.name, f.standard) }).collect(); + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + row_label(ui, "Type"); + let before = k.family; + combo(ui, "pt_family", &refs, &mut k.family); + ui.end_row(); + if k.family != before { + k.size = 0; + k.length = None; + } + let Some(f) = lib.get(k.family) else { return false }; + let sizes: Vec<&str> = f.sizes.iter().map(|s| s.0.as_str()).collect(); + row_label(ui, "Size"); + combo(ui, "pt_size", &sizes, &mut k.size); + ui.end_row(); + if let Some(ls) = &f.lengths { + let mut labels = vec!["Default".to_string()]; + labels.extend(ls.iter().map(|l| format!("{l} mm"))); + let refs: Vec<&str> = labels.iter().map(String::as_str).collect(); + let mut i = k.length.map_or(0, |x| x + 1); + row_label(ui, "Length"); + combo(ui, "pt_len", &refs, &mut i); + k.length = i.checked_sub(1); + ui.end_row(); + } + row_label(ui, "Name"); + ui.add(egui::TextEdit::singleline(&mut k.name).hint_text("automatic")); + ui.end_row(); + // The part, and its table values. + let key = (k.family, k.size, k.length); + let tex_id = egui::Id::new("sc_part_picture"); + if k.picture != Some(key) { + let tex = insert_params(&lib, k, None) + .ok() + .and_then(|p| picture(&p)) + .map(|img| ui.ctx().load_texture("sc_part_picture", img, egui::TextureOptions::LINEAR)); + ui.data_mut(|d| match tex { + Some(t) => { + d.insert_temp(tex_id, t); + } + None => d.remove::(tex_id), + }); + k.picture = Some(key); + } + row_label(ui, ""); + match ui.data(|d| d.get_temp::(tex_id)) { + Some(tx) => { + ui.add(egui::Image::new(&tx).fit_to_exact_size(vec2(180.0, 112.0)).corner_radius(4.0)); + } + None => { + ui.label(RichText::new("no picture").color(t.text_dim)); + } + } + ui.end_row(); + if let Some((_, row)) = f.sizes.get(k.size) + && let Some(o) = row.as_object() + { + for (key, v) in o.iter().filter(|(key, _)| key.as_str() != "size") { + row_label(ui, &key.replace('_', " ")); + ui.label(RichText::new(v.as_f64().map(|x| format!("{x} mm")).unwrap_or_else(|| v.to_string())).color(t.text_dim)); + ui.end_row(); + } + } + if inputs.first().is_some_and(|i| i.items.is_empty()) { + row_label(ui, ""); + ui.label(RichText::new("pick a hole to seat it, or OK places it at the origin").color(t.text_dim)); + ui.end_row(); + } + false +} + +/// The commands OK runs. +pub fn commands(app: &SolveApp, k: &Pt, inputs: &[SelInput]) -> Result, String> { + let lib = match &k.lib { + Some(l) => l.clone(), + None => app.session.scratch().execute("parts.library", &json!({})).map(|v| families(&v)).unwrap_or_default(), + }; + let p = insert_params(&lib, k, inputs.first().and_then(|i| i.items.first()))?; + // The toolbar's command when the engine has it (same parameters). + let id = if solvecraft_engine::find_command("FusionFastenersCommand").is_some() { "FusionFastenersCommand" } else { "parts.insert" }; + Ok(vec![(id.into(), p)]) +} + +#[cfg(test)] +mod tests { + use super::*; + use solvecraft_engine::geom::Vec3; + + fn lib() -> Vec { + families(&json!({"families": [ + {"family": "socket_head_cap_screw", "name": "Socket Head Cap Screw", "standard": "ISO 4762", + "sizes": [{"size": "M5", "head_diameter": 8.5}, {"size": "M6", "head_diameter": 10}], "lengths": [10, 16, 20]}, + {"family": "bearing", "name": "Deep Groove Ball Bearing", "standard": "", "sizes": [{"size": "608", "bore": 8}], "lengths": null}, + ]})) + } + + #[test] + fn library_rows_parse() { + let l = lib(); + assert_eq!(l.len(), 2); + assert_eq!(l[0].sizes[1].0, "M6"); + assert_eq!(l[0].lengths.as_deref(), Some(&[10.0, 16.0, 20.0][..])); + assert!(l[1].lengths.is_none()); + } + + /// A hole pick seats the part (`at`); without one it goes to the origin along Z. + #[test] + fn insert_params_for_picks() { + let l = lib(); + let k = Pt { family: 0, size: 1, length: Some(2), name: String::new(), picture: None, lib: None }; + let hole = Sel::Face { body: "Body1".into(), index: 3, point: Vec3::new(5.0, 0.0, 10.0) }; + let p = insert_params(&l, &k, Some(&hole)).unwrap(); + assert_eq!(p, json!({"family": "socket_head_cap_screw", "size": "M6", "length": 20.0, "at": [5.0, 0.0, 10.0]})); + let k = Pt { family: 1, size: 0, length: None, name: "Front bearing".into(), picture: None, lib: None }; + let p = insert_params(&l, &k, None).unwrap(); + assert_eq!(p["point"], json!([0.0, 0.0, 0.0])); + assert_eq!(p["name"], "Front bearing"); + assert!(p.get("length").is_none()); + assert!(insert_params(&[], &k, None).is_err()); + } +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 0fb7a3f3..66da116d 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -856,6 +856,15 @@ 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); } + // Fastener: a socket head screw seen from the side. + "fastener" => { + pen.poly(&[(6.0, 3.0), (18.0, 3.0), (18.0, 9.0), (6.0, 9.0)], pen.fill, ink); + pen.line(&[(10.0, 3.0), (10.0, 6.0), (14.0, 6.0), (14.0, 3.0)], a); + pen.poly(&[(9.0, 9.0), (15.0, 9.0), (15.0, 21.0), (9.0, 21.0)], pen.fill.gamma_multiply(0.85), ink); + for y in [12.0, 15.0, 18.0] { + pen.line(&[(9.0, y), (15.0, y + 1.5)], ink); + } + } "fold" => { pen.line(&[(2.0, 18.0), (12.0, 18.0)], ink); pen.line(&[(12.0, 18.0), (19.0, 6.0)], a); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ae567538..28457c1f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -19,6 +19,7 @@ pub mod dialogs_appearance; pub mod dialogs_assembly; pub mod dialogs_motion; pub mod dialogs_move; +pub mod dialogs_parts; pub mod dialogs_plastic; pub mod dialogs_sheet; pub mod dim_view;