Document Settings: design units (document.units: mm, cm, m, in, ft; values keep their meaning) from the browser's Units row, display precision row; new designs take the preferred units; the Properties window shows lengths, areas and volumes in the design's units with the chosen precision

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:09:31 +00:00
1 parent c99e7e41ec
commit 13070abc55
6 files changed
+127 -17

No files matched your search

+29
View File
@@ -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<Value> {
Ok(json!({}))
}
fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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();
+13 -1
View File
@@ -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: &params, ..Default::default() }).clicked {
app.start("ChangeParameterCommand");
+26 -10
View File
@@ -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<String> {
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<Item> {
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<String> = c.iter().map(|x| format!("{:.3}", x.as_f64().unwrap_or(f64::NAN))).collect();
let c: Vec<String> = 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<String> =
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<String> = 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();
}
}
+1
View File
@@ -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.
+14
View File
@@ -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");
}
+44 -6
View File
@@ -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));