diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index 347e2acf..ce488c55 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -24,6 +24,9 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("browser.rename_group", "Rename Group", rename_group).params("group: id, name"), CommandSpec::new("browser.move", "Move Browser Items", move_items) .params("folder, component?, items: [keys]; group?: id (into it; omit to take them out of groups); before?: key (position in the group)"), + CommandSpec::new("document.units", "Change Design Units", document_units) + .icon("settings") + .params("units: mm|cm|m|in|ft (display and unit-less parameters; values keep their meaning)"), CommandSpec::new("view.save", "New Named View", view_save) .icon("camera") .params("name?; camera: {target: [x,y,z], yaw, pitch, distance, fov?} (replaces a view of the same name)"), @@ -152,6 +155,18 @@ fn order(s: &mut Session, p: &Value) -> Result { Ok(json!({})) } +fn document_units(s: &mut Session, p: &Value) -> Result { + let cmd = "document.units"; + let u = str_(p, "units").ok_or_else(|| bad(cmd, "`units` is required"))?; + if !["mm", "cm", "m", "in", "ft"].contains(&u) { + return Err(bad(cmd, "`units` must be mm, cm, m, in or ft")); + } + if s.doc.units != u { + s.doc_mut().units = u.to_string(); + } + Ok(json!({ "units": u })) +} + fn view_save(s: &mut Session, p: &Value) -> Result { let cmd = "view.save"; let c = p.get("camera").ok_or_else(|| bad(cmd, "`camera` is required"))?; @@ -365,6 +380,20 @@ mod tests { assert!(s.execute("sketch.redefine", &json!({"sketch": 999, "plane": "XY"})).is_err()); } + #[test] + fn design_units_change_without_moving_geometry() { + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 20, "width": 10, "height": 5})); + let vol = |s: &mut Session| run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap(); + let v0 = vol(&mut s); + run(&mut s, "document.units", json!({"units": "in"})); + assert_eq!(s.doc.units, "in"); + assert!((vol(&mut s) - v0).abs() < 1e-6, "feature inputs keep their millimetres"); + assert!(s.execute("document.units", &json!({"units": "furlong"})).is_err()); + run(&mut s, "UndoCommand", json!({})); + assert_eq!(s.doc.units, "mm"); + } + #[test] fn named_views_are_saved_renamed_and_deleted() { let mut s = Session::default(); diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 62158ef2..b5fb4ae0 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -558,7 +558,19 @@ fn doc_settings(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) { return; } let units = format!("Units: {}", app.session.doc.units); - draw_row(ui, ui.id().with("units"), &Row { depth: depth + 1, icon: "settings", label: &units, ..Default::default() }); + let r = draw_row(ui, ui.id().with("units"), &Row { depth: depth + 1, icon: "settings", label: &units, ..Default::default() }); + if let Some(p) = r.secondary.or(r.clicked.then(|| r.rect.map(|x| x.left_bottom())).flatten()) { + crate::context_menu::open_for(app, p, Target::Units); + } + let prec = format!( + "Precision: {} {}, {}°", + format_args!("{:.*}", usize::from(app.preferences.length_decimals), 0.0), + crate::prefs::unit_scale(&app.session.doc.units).1, + format_args!("{:.*}", usize::from(app.preferences.angle_decimals), 0.0) + ); + if draw_row(ui, ui.id().with("precision"), &Row { depth: depth + 1, icon: "settings", label: &prec, ..Default::default() }).clicked { + app.prefs_window.open = true; + } let params = format!("Parameters ({})", app.session.doc.params.len()); if draw_row(ui, ui.id().with("params"), &Row { depth: depth + 1, icon: "params", label: ¶ms, ..Default::default() }).clicked { app.start("ChangeParameterCommand"); diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index c0ad274a..09136374 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -40,6 +40,8 @@ pub enum Target { }, /// The Origin folder. Origin, + /// Document Settings › Units. + Units, /// The Named Views folder, and one view (saved or standard) in it. NamedViews, NamedView { @@ -213,9 +215,13 @@ pub fn target_bodies(app: &SolveApp, target: &Target) -> Vec { Target::Body { name } => vec![name.clone()], Target::Component { id } => component_bodies(app, *id), Target::Group { id } => crate::browser::group_bodies(app, *id), - Target::Sketch { .. } | Target::Folder { .. } | Target::Canvas { .. } | Target::Origin | Target::NamedViews | Target::NamedView { .. } => { - Vec::new() - } + Target::Sketch { .. } + | Target::Folder { .. } + | Target::Canvas { .. } + | Target::Origin + | Target::Units + | Target::NamedViews + | Target::NamedView { .. } => Vec::new(), } } @@ -275,6 +281,13 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec { Target::Canvas { id } => crate::browser::canvas_items(app, *id), Target::Origin => crate::browser::origin_items(app), Target::NamedViews => vec![act("ui.newView", "New Named View", "perspective")], + Target::Units => crate::prefs::UNITS + .iter() + .map(|u| { + let label = if app.session.doc.units == *u { format!("{u} ✓") } else { (*u).to_string() }; + cmd(app, "document.units", &label).with(json!({ "units": u })) + }) + .collect(), Target::NamedView { name } => crate::browser::view_items(app, name), }; // Several items of the folder selected: they can be grouped. @@ -1380,24 +1393,27 @@ fn show_props(app: &mut SolveApp, ctx: &egui::Context) { ui.label(format!("{:.3} g", f(total, "mass_g"))); ui.end_row(); ui.label("Volume"); - ui.label(format!("{:.3} mm³", f(total, "volume_mm3"))); + ui.label(crate::prefs::show_mm(app, f(total, "volume_mm3"), 3)); ui.end_row(); ui.label("Area"); - ui.label(format!("{:.3} mm²", f(total, "area_mm2"))); + ui.label(crate::prefs::show_mm(app, f(total, "area_mm2"), 2)); ui.end_row(); if let [b] = bodies.as_slice() { if let Some(c) = b["center_of_mass"].as_array() { - let c: Vec = c.iter().map(|x| format!("{:.3}", x.as_f64().unwrap_or(f64::NAN))).collect(); + let c: Vec = c.iter().map(|x| crate::prefs::show_mm_bare(app, x.as_f64().unwrap_or(f64::NAN))).collect(); ui.label("Center of mass"); - ui.label(format!("({}) mm", c.join(", "))); + ui.label(format!("({}) {}", c.join(", "), crate::prefs::unit_scale(&app.session.doc.units).1)); ui.end_row(); } let (mn, mx) = (&b["bbox"]["min"], &b["bbox"]["max"]); if let (Some(a), Some(z)) = (mn.as_array(), mx.as_array()) { - let d: Vec = - a.iter().zip(z).map(|(a, z)| format!("{:.3}", z.as_f64().unwrap_or(0.0) - a.as_f64().unwrap_or(0.0))).collect(); + let d: Vec = a + .iter() + .zip(z) + .map(|(a, z)| crate::prefs::show_mm_bare(app, z.as_f64().unwrap_or(0.0) - a.as_f64().unwrap_or(0.0))) + .collect(); ui.label("Bounding box"); - ui.label(format!("{} mm", d.join(" × "))); + ui.label(format!("{} {}", d.join(" × "), crate::prefs::unit_scale(&app.session.doc.units).1)); ui.end_row(); } } diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 0b6ffa81..35a3286f 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -166,6 +166,7 @@ pub fn new_design(app: &mut SolveApp) { if let Err(e) = open_tab(app, Session::default()) { app.set_status(e, true); } + crate::prefs::apply_new_design(app); } /// Before opening a file: a fresh tab unless the active design is blank. diff --git a/crates/ui-egui/src/menu_tests.rs b/crates/ui-egui/src/menu_tests.rs index a5ec7a28..8660f962 100644 --- a/crates/ui-egui/src/menu_tests.rs +++ b/crates/ui-egui/src/menu_tests.rs @@ -402,3 +402,17 @@ fn named_views_save_restore_rename_and_delete() { context_menu::run_item(&mut app, &find(&top, "Restore"), pos2(0.0, 0.0)); assert!(app.cam_anim.is_some()); } + +#[test] +fn units_menu_changes_the_design_units_and_new_designs_take_the_preference() { + let mut app = sample_app(); + let items = context_menu::items(&app, &Target::Units); + assert_eq!(items.len(), 5); + act(&mut app, &Target::Units, "in"); + assert_eq!(app.session.doc.units, "in"); + assert!(crate::prefs::show_mm(&app, 25.4, 1).starts_with("1.00 in")); + app.preferences.default_units = "cm".into(); + crate::documents::new_design(&mut app); + assert_eq!(app.session.doc.units, "cm"); + assert!(!app.session.is_dirty(), "a fresh design stays unmodified"); +} diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index e13cd688..c086e583 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -56,6 +56,19 @@ pub struct PrefsWindow { section: usize, } +/// A new, empty design takes the preferred units (not an undo step, not a change to save). +pub fn apply_new_design(app: &mut SolveApp) { + if app.session.doc.features.is_empty() && app.session.doc.units != app.preferences.default_units { + let saved = !app.session.is_dirty(); + if app.session.execute("document.units", &json!({ "units": app.preferences.default_units })).is_ok() { + app.session.undo.pop(); + if saved { + app.session.mark_saved(); + } + } + } +} + /// The autosave interval follows `app.autosave_minutes` (0: off). pub fn set_autosave(app: &mut SolveApp) { let m = app.autosave_minutes; @@ -141,18 +154,41 @@ fn pivot_under(app: &SolveApp, proj: &crate::viewport::Proj, at: Pos2) -> Vec3 { if den.abs() > 1e-9 { o + d * ((app.cam.target - o).dot(n) / den) } else { app.cam.target } } -/// A length in millimetres in `units`, with `decimals` (for display). -pub fn format_length(mm: f64, units: &str, decimals: u8) -> String { - let (k, u) = match units { +/// Units per millimetre and the unit's name. +pub fn unit_scale(units: &str) -> (f64, &'static str) { + match units { "cm" => (0.1, "cm"), "m" => (0.001, "m"), "in" => (1.0 / 25.4, "in"), "ft" => (1.0 / 304.8, "ft"), _ => (1.0, "mm"), - }; + } +} + +/// A length in millimetres in `units`, with `decimals` (for display). +pub fn format_length(mm: f64, units: &str, decimals: u8) -> String { + let (k, u) = unit_scale(units); format!("{:.*} {u}", usize::from(decimals.min(8)), mm * k) } +/// A length, area (`power` 2) or volume (3) given in millimetres, shown in the design's units +/// with the length precision. +pub fn show_mm(app: &SolveApp, v: f64, power: i32) -> String { + let (k, u) = unit_scale(&app.session.doc.units); + let sup = match power { + 2 => "²", + 3 => "³", + _ => "", + }; + format!("{:.*} {u}{sup}", usize::from(app.preferences.length_decimals.min(8)), v * k.powi(power)) +} + +/// Numbers in millimetres, in the design's units without the unit name. +pub fn show_mm_bare(app: &SolveApp, v: f64) -> String { + let (k, _) = unit_scale(&app.session.doc.units); + format!("{:.*}", usize::from(app.preferences.length_decimals.min(8)), v * k) +} + /// Write the anti-aliasing setting where the host reads it at start (the window's samples can't /// change while it is open). fn save_graphics(app: &SolveApp) { @@ -201,8 +237,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } }); ui.end_row(); - ui.label("Recovery copy every"); - ui.add(egui::DragValue::new(&mut app.preferences.autosave_minutes).range(0..=120).suffix(" min")); + ui.label("Autosave every"); + if ui.add(egui::DragValue::new(&mut app.autosave_minutes).range(0.0..=120.0).speed(0.5).suffix(" min")).changed() { + set_autosave(app); + } ui.end_row(); ui.label(""); ui.label(RichText::new("0 turns it off").size(11.0).color(t.text_dim));