diff --git a/ROADMAP.md b/ROADMAP.md index f8434677..8e895741 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -73,6 +73,11 @@ headline counts in-scope tabs only and lists the deferred ones separately. Type a value and press Enter to commit one undo step; rejected values remain editable and cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette and navigation controls, including enlarged displays and nested pending placements. +- Sketch annotations, live drawing lengths and Measure use document units. Inches show + three decimals and retain a meaningful fourth digit. Bare length dimension entry captures + the current design unit; changing units leaves existing geometry and expressions intact. + Component/canvas distance controls, sketch cursor coordinates, flat-pattern dimensions + and pocket depths, interference volumes and parts-library lengths also use design units. - Curvature and Polygon constraints accept complete preselected operands. Fix/Unfix consumes complete point/curve selections consistently; mixed or ambiguous selections retain the picking workflow. Rejected constraints preserve selection and undo history. diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 17fc3339..53860a24 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -103,7 +103,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())), CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!(), created_doc!())), CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params( - "entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current); text_at?: [x, y]. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)", + "entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override); text_at?: [x, y]. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)", ), CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical) .at("SKETCH", "CONSTRAINTS") @@ -1601,7 +1601,7 @@ fn dimension(s: &mut Session, p: &Value) -> Result { } let (unit, default_expr) = if is_angle { ("deg", format!("{} deg", round6(cur.to_degrees()))) } else { ("mm", format!("{} mm", round6(cur))) }; - let e = value.clone().unwrap_or(default_expr); + let e = value.as_ref().map(|e| doc.with_design_unit(e, if is_angle { Kind::Angle } else { Kind::Length })).unwrap_or(default_expr); let pname = doc.new_model_param(&e, unit); // The value must evaluate. let (vals, errs) = doc.param_values(); diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index b904b633..3af128a2 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -747,7 +747,7 @@ fn doc_settings(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) { } let prec = format!( "Precision: {} {}, {}°", - format_args!("{:.*}", usize::from(app.preferences.length_decimals), 0.0), + crate::prefs::show_mm_bare(app, 0.0), crate::prefs::unit_scale(&app.session.doc.units).1, format_args!("{:.*}", usize::from(app.preferences.angle_decimals), 0.0) ); @@ -1011,24 +1011,24 @@ fn canvas_panel(app: &mut SolveApp, ctx: &egui::Context) { for (label, p) in [("Point A", &mut m.a), ("Point B", &mut m.b)] { ui.label(label); ui.horizontal(|ui| { - ui.add(egui::DragValue::new(&mut p[0]).speed(0.5).suffix(" mm")); - ui.add(egui::DragValue::new(&mut p[1]).speed(0.5).suffix(" mm")); + ui.add(crate::prefs::length_drag(&mut p[0], &app.session.doc.units, app.preferences.length_decimals)); + ui.add(crate::prefs::length_drag(&mut p[1], &app.session.doc.units, app.preferences.length_decimals)); }); ui.end_row(); } ui.label("Distance"); - ui.add(egui::DragValue::new(&mut m.distance).speed(0.5).range(0.001..=1e6).suffix(" mm")); + ui.add(crate::prefs::length_drag(&mut m.distance, &app.session.doc.units, app.preferences.length_decimals).range(0.001..=1e6)); ui.end_row(); } else { let (mut x, mut y, mut w, mut ang, mut op, mut flip) = (c.center.x, c.center.y, c.width, c.angle.to_degrees(), c.opacity, c.flip); ui.label("Center X"); - let r1 = ui.add(egui::DragValue::new(&mut x).speed(0.5).suffix(" mm")); + let r1 = ui.add(crate::prefs::length_drag(&mut x, &app.session.doc.units, app.preferences.length_decimals)); ui.end_row(); ui.label("Center Y"); - let r2 = ui.add(egui::DragValue::new(&mut y).speed(0.5).suffix(" mm")); + let r2 = ui.add(crate::prefs::length_drag(&mut y, &app.session.doc.units, app.preferences.length_decimals)); ui.end_row(); ui.label("Width"); - let r3 = ui.add(egui::DragValue::new(&mut w).speed(0.5).range(0.01..=1e6).suffix(" mm")); + let r3 = ui.add(crate::prefs::length_drag(&mut w, &app.session.doc.units, app.preferences.length_decimals).range(0.01..=1e6)); ui.end_row(); ui.label("Angle"); let r4 = ui.add(egui::DragValue::new(&mut ang).speed(1.0).suffix(" deg")); @@ -1573,7 +1573,7 @@ fn occurrence_panel(app: &mut SolveApp, ui: &mut egui::Ui) { for (k, axis) in ["X distance", "Y distance", "Z distance"].iter().enumerate() { ui.label(*axis); if let Some(v) = m.translate.get_mut(k) { - let response = ui.add(egui::DragValue::new(v).speed(0.5).suffix(" mm")); + let response = ui.add(crate::prefs::length_drag(v, &app.session.doc.units, app.preferences.length_decimals)); crate::scenario::publish_handle(&format!("occurrence-move:distance:{k}"), response.rect.center()); } ui.end_row(); @@ -1709,7 +1709,11 @@ pub fn dims_overlay(app: &SolveApp, ctx: &egui::Context) { } let expr = c.param.as_ref().and_then(|p| app.session.doc.param(p)).map(|p| p.expr.as_str()); let col = if c.driven { tk.dimension_driven } else { tk.dimension }; - let galley = painter.layout_no_wrap(crate::dim_view::label(&c.kind, expr, c.driven), FontId::proportional(11.5), col); + let galley = painter.layout_no_wrap( + crate::dim_view::label(&c.kind, expr, c.driven, &app.session.doc.units, app.preferences.length_decimals), + FontId::proportional(11.5), + col, + ); let size = galley.size() + vec2(6.0, 2.0); let half = Vec2::new(f64::from(size.x) * 0.5 * px, f64::from(size.y) * 0.5 * px); let lay = dim_layout(&frame, c.text.or_else(|| default_text(&frame, middle, px)), px, half); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a9c488a2..8a72aba9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -125,12 +125,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj app.dialog = Some(d); return; } - if crate::dialogs_sheet::overlay(painter, proj.rect, &d) { + if crate::dialogs_sheet::overlay(app, painter, proj.rect, &d) { app.dialog = Some(d); return; } if let Kind::Measure { result: Some(r), .. } = &d.kind { - measure_line(painter, proj, r); + measure_line(app, painter, proj, r); app.dialog = Some(d); return; } @@ -380,7 +380,7 @@ fn drag_arrow( } /// The measured distance: a line between the closest points with its length. -fn measure_line(painter: &egui::Painter, proj: &Proj, r: &serde_json::Value) { +fn measure_line(app: &SolveApp, painter: &egui::Painter, proj: &Proj, r: &serde_json::Value) { let t = Tokens::get(); let p = |k: &str| -> Option { let a = r.get(k)?.as_array()?; @@ -393,7 +393,7 @@ fn measure_line(painter: &egui::Painter, proj: &Proj, r: &serde_json::Value) { painter.circle(s, 3.5, t.manipulator, Stroke::new(1.0, Color32::WHITE)); } let mid = sa + (sb - sa) * 0.5 + vec2(10.0, -14.0); - let galley = painter.layout_no_wrap(format!("{d:.3} mm"), egui::FontId::proportional(12.5), t.text); + let galley = painter.layout_no_wrap(crate::prefs::show_mm(app, d, 1), egui::FontId::proportional(12.5), t.text); let rect = egui::Rect::from_min_size(mid, galley.size() + vec2(10.0, 6.0)); painter.rect(rect, 3.0, t.overlay, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); painter.galley(rect.min + vec2(5.0, 3.0), galley, t.text); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 64afee2f..5459a785 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1200,12 +1200,12 @@ fn update_measure(app: &SolveApp, d: &mut Dialog) { } } -fn fmt_mm(v: &Value) -> String { - v.as_f64().map(|x| format!("{x:.3} mm")).unwrap_or_default() +fn fmt_mm(app: &SolveApp, v: &Value) -> String { + v.as_f64().map(|x| crate::prefs::show_mm(app, x, 1)).unwrap_or_default() } /// The measurement in the dialog. -fn measure_rows(ui: &mut egui::Ui, r: Option<&Value>) { +fn measure_rows(app: &SolveApp, ui: &mut egui::Ui, r: Option<&Value>) { let t = Tokens::get(); let Some(r) = r else { ui.label(""); @@ -1221,11 +1221,11 @@ fn measure_rows(ui: &mut egui::Ui, r: Option<&Value>) { } for (i, it) in r["items"].as_array().into_iter().flatten().enumerate() { let what = match it["type"].as_str() { - Some("edge") => format!("Length {}", fmt_mm(&it["length_mm"])), - Some("face") => format!("Area {:.3} mm²", it["area_mm2"].as_f64().unwrap_or(0.0)), + Some("edge") => format!("Length {}", fmt_mm(app, &it["length_mm"])), + Some("face") => format!("Area {}", crate::prefs::show_mm(app, it["area_mm2"].as_f64().unwrap_or(0.0), 2)), Some("vertex") => { let p = &it["position"]; - format!("At {:.3}, {:.3}, {:.3}", p[0].as_f64().unwrap_or(0.0), p[1].as_f64().unwrap_or(0.0), p[2].as_f64().unwrap_or(0.0)) + format!("At {}, {}, {}", fmt_mm(app, &p[0]), fmt_mm(app, &p[1]), fmt_mm(app, &p[2])) } _ => String::new(), }; @@ -1235,12 +1235,12 @@ fn measure_rows(ui: &mut egui::Ui, r: Option<&Value>) { } if r.get("distance_mm").is_some() { ui.label(RichText::new("Distance").strong()); - ui.label(RichText::new(fmt_mm(&r["distance_mm"])).strong()); + ui.label(RichText::new(fmt_mm(app, &r["distance_mm"])).strong()); ui.end_row(); let d = &r["delta"]; for (k, l) in ["ΔX", "ΔY", "ΔZ"].iter().enumerate() { ui.label(*l); - ui.label(format!("{:.3} mm", d[k].as_f64().unwrap_or(0.0).abs())); + ui.label(crate::prefs::show_mm(app, d[k].as_f64().unwrap_or(0.0).abs(), 1)); ui.end_row(); } } @@ -1638,7 +1638,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { d.extra.insert("combine".into(), json!(op == 1)); ui.end_row(); } - Kind::Measure { result, .. } => measure_rows(ui, result.as_ref()), + Kind::Measure { result, .. } => measure_rows(app, ui, result.as_ref()), Kind::OffsetPlane { offset } => { row_label(ui, "Distance"); enter |= field(ui, offset); diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 354b8bab..21a654e4 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -591,7 +591,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se } ui.end_row(); if let Some(r) = result.as_ref() { - interference_rows(ui, r); + interference_rows(app, ui, r); } } } @@ -623,7 +623,7 @@ fn interference(app: &mut SolveApp, picked: &[Sel]) -> (Value, Vec) { (r, meshes) } -fn interference_rows(ui: &mut egui::Ui, r: &Value) { +fn interference_rows(app: &SolveApp, ui: &mut egui::Ui, r: &Value) { let t = Tokens::get(); if let Some(e) = r.get("error").and_then(Value::as_str) { row_label(ui, ""); @@ -646,7 +646,7 @@ fn interference_rows(ui: &mut egui::Ui, r: &Value) { ui.end_row(); for h in hits { row_label(ui, &format!("{} · {}", h["a"].as_str().unwrap_or(""), h["b"].as_str().unwrap_or(""))); - ui.label(RichText::new(format!("{:.3} mm³", h["volume_mm3"].as_f64().unwrap_or(0.0))).color(t.error)); + ui.label(RichText::new(crate::prefs::show_mm(app, h["volume_mm3"].as_f64().unwrap_or(0.0), 3)).color(t.error)); ui.end_row(); } } diff --git a/crates/ui-egui/src/dialogs_parts.rs b/crates/ui-egui/src/dialogs_parts.rs index 75629cab..e2afd05b 100644 --- a/crates/ui-egui/src/dialogs_parts.rs +++ b/crates/ui-egui/src/dialogs_parts.rs @@ -145,7 +145,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Pt, inputs: &[SelInpu 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"))); + labels.extend(ls.iter().map(|l| crate::prefs::show_mm(app, *l, 1))); 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"); @@ -187,7 +187,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Pt, inputs: &[SelInpu { 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.label(RichText::new(v.as_f64().map(|x| crate::prefs::show_mm(app, x, 1)).unwrap_or_else(|| v.to_string())).color(t.text_dim)); ui.end_row(); } } diff --git a/crates/ui-egui/src/dialogs_sheet.rs b/crates/ui-egui/src/dialogs_sheet.rs index 92a73924..94f7528c 100644 --- a/crates/ui-egui/src/dialogs_sheet.rs +++ b/crates/ui-egui/src/dialogs_sheet.rs @@ -333,10 +333,10 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel 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) + "{} × {} × {}", + crate::prefs::show_mm(app, size[0].as_f64().unwrap_or(0.0), 1), + crate::prefs::show_mm(app, size[1].as_f64().unwrap_or(0.0), 1), + crate::prefs::show_mm(app, size[2].as_f64().unwrap_or(0.0), 1) )); ui.end_row(); let bends = v["bends"].as_array().cloned().unwrap_or_default(); @@ -349,10 +349,10 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel row_label(ui, &format!(" Bend {}", b["bend"].as_u64().unwrap_or(0))); ui.label( RichText::new(format!( - "{} {:.1}° R{:.2}", + "{} {:.1}° R{}", if up { "Up" } else { "Down" }, b["angle_deg"].as_f64().unwrap_or(0.0), - b["radius"].as_f64().unwrap_or(0.0) + crate::prefs::show_mm(app, b["radius"].as_f64().unwrap_or(0.0), 1) )) .color(if up { up_col } else { down_col }), ); @@ -691,7 +691,7 @@ fn bend_colors() -> (Color32, Color32) { /// 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 { +pub fn overlay(app: &SolveApp, 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(); @@ -757,7 +757,7 @@ pub fn overlay(painter: &egui::Painter, rect: Rect, d: &Dialog) -> bool { if !outer.is_empty() { let bounds = outer.iter().map(|p| to(*p)).fold(Rect::NOTHING, |r, p| r.union(Rect::from_min_max(p, p))); let anchor = bounds.center_top() - vec2(0.0, 6.0); - if let Some(label) = pocket_label(pocket) { + if let Some(label) = pocket_label(app, pocket) { if let Some((_, labels)) = pocket_annotations.iter_mut().find(|(at, _)| at.distance(anchor) < 1.0) { if !labels.contains(&label) { labels.push(label); @@ -797,7 +797,7 @@ pub fn overlay(painter: &egui::Painter, rect: Rect, d: &Dialog) -> bool { true } -fn pocket_label(pocket: &Value) -> Option { +fn pocket_label(app: &SolveApp, pocket: &Value) -> Option { let depth = pocket["depth_mm"].as_f64()?; if !depth.is_finite() || depth <= 0.0 { return None; @@ -808,7 +808,7 @@ fn pocket_label(pocket: &Value) -> Option { "internal" => "Internal", _ => return None, }; - Some(format!("{side} pocket: {depth} mm deep")) + Some(format!("{side} pocket: {} deep", crate::prefs::show_mm(app, depth, 1))) } #[cfg(test)] diff --git a/crates/ui-egui/src/dialogs_sheet_tests.rs b/crates/ui-egui/src/dialogs_sheet_tests.rs index 16ccfbb7..1027824f 100644 --- a/crates/ui-egui/src/dialogs_sheet_tests.rs +++ b/crates/ui-egui/src/dialogs_sheet_tests.rs @@ -145,7 +145,7 @@ fn shallow_pocket_overlay_labels_depth_without_drawing_a_through_void() { let ctx = egui::Context::default(); let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("flat-pocket-test"))); - assert!(overlay(&painter, Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0)), &dialog)); + assert!(overlay(&app, &painter, Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0)), &dialog)); }); output.textures_delta.clear(); let texts: Vec<&str> = output @@ -156,7 +156,7 @@ fn shallow_pocket_overlay_labels_depth_without_drawing_a_through_void() { _ => None, }) .collect(); - assert!(texts.contains(&"Bottom pocket: 1 mm deep")); + assert!(texts.contains(&"Bottom pocket: 1.00 mm deep")); assert!(texts.contains(&"Pocket boundary (material remains)")); let paths: Vec<_> = output .shapes @@ -198,7 +198,7 @@ fn flat_pattern_islands_fit_before_the_actual_command_panel() { ui.label("Flat size: 50 × 10 × 2.5 mm"); }); let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Background, egui::Id::new("island-fit"))); - assert!(overlay(&painter, Rect::from_min_max(pos2(250.0, 126.0), pos2(1400.0, 832.0)), &dialog)); + assert!(overlay(&app, &painter, Rect::from_min_max(pos2(250.0, 126.0), pos2(1400.0, 832.0)), &dialog)); }); output.textures_delta.clear(); } @@ -237,7 +237,7 @@ fn coincident_top_and_bottom_pocket_depths_share_a_readable_annotation() { let ctx = egui::Context::default(); let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("coincident-pocket-test"))); - assert!(overlay(&painter, Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0)), &dialog)); + assert!(overlay(&app, &painter, Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0)), &dialog)); }); output.textures_delta.clear(); let labels: Vec<_> = output @@ -249,8 +249,8 @@ fn coincident_top_and_bottom_pocket_depths_share_a_readable_annotation() { }) .collect(); assert_eq!(labels.len(), 1, "coincident depths must not overwrite one another"); - assert!(labels[0].contains("Top pocket: 1 mm deep")); - assert!(labels[0].contains("Bottom pocket: 0.5 mm deep")); + assert!(labels[0].contains("Top pocket: 1.00 mm deep")); + assert!(labels[0].contains("Bottom pocket: 0.50 mm deep")); assert!(labels[0].contains('\n')); assert_eq!(app.session.doc.to_json(), doc); } @@ -310,3 +310,11 @@ fn bend_table_picker_writes_the_current_body() { assert!(contents.contains(",90.000000000,up,2.500000000,"), "{contents}"); assert_eq!(app.session.undo.len(), undo); } + +#[test] +fn pocket_depth_labels_follow_inches_without_changing_flat_data() { + let mut app = plate(); + app.run("document.units", json!({"units":"in"})).unwrap(); + assert_eq!(super::pocket_label(&app, &json!({"side":"top","depth_mm":25.4*0.1234})).as_deref(), Some("Top pocket: 0.1234 in deep")); + assert!(super::pocket_label(&app, &json!({"side":"top","depth_mm":-1})).is_none()); +} diff --git a/crates/ui-egui/src/dim_view.rs b/crates/ui-egui/src/dim_view.rs index 3a2e668d..cdba306a 100644 --- a/crates/ui-egui/src/dim_view.rs +++ b/crates/ui-egui/src/dim_view.rs @@ -290,9 +290,18 @@ pub fn drag_end() { /// The text shown for a dimension: the value (R / Ø for radius and diameter, degrees for /// angles), "fx: value" when an expression drives it, in parentheses when driven. -pub fn label(k: &ConstraintKind, expr: Option<&str>, driven: bool) -> String { +pub fn label(k: &ConstraintKind, expr: Option<&str>, driven: bool, units: &str, decimals: u8) -> String { let v = k.value().unwrap_or(0.0); - let num = if k.is_angle() { format!("{:.1}°", v.to_degrees()) } else { format!("{v:.2}") }; + // Retain deliberately authored trailing zeros in a four-place inch literal too. + let authored_four = units == "in" + && expr.is_some_and(|e| { + e.trim().strip_suffix("in").map(str::trim).is_some_and(|number| { + number.parse::().is_ok() + && number.split_once('.').is_some_and(|(_, fraction)| fraction.len() >= 4 && fraction.bytes().all(|b| b.is_ascii_digit())) + }) + }); + let decimals = if authored_four { decimals.max(4) } else { decimals }; + let num = if k.is_angle() { format!("{:.1}°", v.to_degrees()) } else { crate::prefs::length_number(v, units, decimals) }; let num = match k { ConstraintKind::Radius { .. } => format!("R{num}"), ConstraintKind::Diameter { .. } | ConstraintKind::LinearDiameter { .. } => format!("Ø{num}"), @@ -300,7 +309,7 @@ pub fn label(k: &ConstraintKind, expr: Option<&str>, driven: bool) -> String { }; let plain = expr.is_none_or(|e| { let e = e.trim(); - let e = e.strip_suffix("mm").or_else(|| e.strip_suffix("deg")).or_else(|| e.strip_suffix('°')).unwrap_or(e); + let e = ["mm", "cm", "in", "ft", "m", "deg", "rad", "°"].iter().find_map(|unit| e.strip_suffix(unit)).unwrap_or(e); e.trim().parse::().is_ok() }); let text = if plain { num } else { format!("fx: {num}") }; @@ -392,7 +401,8 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P } else { tk.dimension }; - let galley = painter.layout_no_wrap(label(&c.kind, expr, c.driven), font.clone(), col); + let galley = + painter.layout_no_wrap(label(&c.kind, expr, c.driven, &app.session.doc.units, app.preferences.length_decimals), font.clone(), col); let size = galley.size() + vec2(6.0, 2.0); let half = Vec2::new(f64::from(size.x) * 0.5 * px, f64::from(size.y) * 0.5 * px); // Unplaced linear dimensions sit outside the shape they measure. @@ -528,7 +538,15 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) { Err("The sketch changed; place the dimension again".into()) } } else { - app.run("parameters.change", json!({"name": e.param, "expression": e.expr})) + let kind = app + .session + .doc + .param(&e.param) + .and_then(|p| solvecraft_engine::doc::expr::unit_info(&p.unit)) + .map(|(k, _)| k) + .unwrap_or(solvecraft_engine::doc::expr::Kind::Unitless); + let expression = app.session.doc.with_design_unit(&e.expr, kind); + app.run("parameters.change", json!({"name": e.param, "expression": expression})) }; if let Err(err) = result { app.set_status(err, true); @@ -621,15 +639,33 @@ mod tests { cancel(); } + #[test] + fn inch_labels_convert_all_lengths_preserve_four_digits_and_leave_angles_alone() { + let length = ConstraintKind::Length { l: 0, value: 25.4 }; + assert_eq!(label(&length, Some("25.4 mm"), false, "in", 2), "1.000"); + assert_eq!(label(&length, Some("1 in"), false, "in", 2), "1.000"); + assert_eq!(label(&length, Some("1.0000 in"), false, "in", 2), "1.0000"); + let radius = ConstraintKind::Radius { c: 0, value: 1.2345 * 25.4 }; + assert_eq!(label(&radius, Some("1.2345 in"), false, "in", 2), "R1.2345"); + assert_eq!(label(&radius, Some("width / 2"), true, "in", 2), "(fx: R1.2345)"); + let diameter = ConstraintKind::Diameter { c: 0, value: 0.001 * 25.4 }; + assert_eq!(label(&diameter, None, false, "in", 2), "Ø0.001"); + assert_eq!(crate::prefs::length_number(25.4 * (1.0 + 1e-12), "in", 2), "1.000"); + assert_eq!(crate::prefs::length_number(25.4, "in", 5), "1.00000"); + assert_eq!(crate::prefs::length_number(25.4, "cm", 2), "2.54"); + let angle = ConstraintKind::Angle { a: 0, b: 1, value: std::f64::consts::FRAC_PI_4, flip: false }; + assert_eq!(label(&angle, Some("45 deg"), false, "in", 2), "45.0°"); + } + #[test] fn labels_show_the_value_or_fx() { let k = ConstraintKind::Length { l: 0, value: 25.0 }; - assert_eq!(label(&k, Some("25 mm"), false), "25.00"); - assert_eq!(label(&k, Some("width * 2"), false), "fx: 25.00"); - assert_eq!(label(&k, None, true), "(25.00)"); - assert_eq!(label(&ConstraintKind::Radius { c: 0, value: 5.0 }, Some("5 mm"), false), "R5.00"); - assert_eq!(label(&ConstraintKind::Diameter { c: 0, value: 10.0 }, Some("10"), false), "Ø10.00"); + assert_eq!(label(&k, Some("25 mm"), false, "mm", 2), "25.00"); + assert_eq!(label(&k, Some("width * 2"), false, "mm", 2), "fx: 25.00"); + assert_eq!(label(&k, None, true, "mm", 2), "(25.00)"); + assert_eq!(label(&ConstraintKind::Radius { c: 0, value: 5.0 }, Some("5 mm"), false, "mm", 2), "R5.00"); + assert_eq!(label(&ConstraintKind::Diameter { c: 0, value: 10.0 }, Some("10"), false, "mm", 2), "Ø10.00"); let a = ConstraintKind::Angle { a: 0, b: 1, value: std::f64::consts::FRAC_PI_4, flip: false }; - assert_eq!(label(&a, Some("45 deg"), false), "45.0°"); + assert_eq!(label(&a, Some("45 deg"), false, "mm", 2), "45.0°"); } } diff --git a/crates/ui-egui/src/dimension_create_ui_tests.rs b/crates/ui-egui/src/dimension_create_ui_tests.rs index 15f2d74a..0bd259be 100644 --- a/crates/ui-egui/src/dimension_create_ui_tests.rs +++ b/crates/ui-egui/src/dimension_create_ui_tests.rs @@ -290,3 +290,36 @@ fn draft_annotation_does_not_move_committed_dimension_hitboxes() { h.step(&json!({"key":"Escape"})).unwrap(); assert_eq!(crate::dim_view::drawn(), drawn); } + +#[test] +fn inch_dimension_entry_survives_unit_changes_undo_and_reopen() { + let mut h = line([25.4, 0.]); + h.app.run("document.units", json!({"units":"in"})).unwrap(); + place(&mut h, &[[12.7, 0.]], [12.7, 8.]); + type_value(&mut h, "1.2345"); + h.step(&json!({"key":"Enter"})).unwrap(); + let sketch = h.app.session.active_sketch.unwrap(); + h.settle(); + crate::dim_view::edit_param(&h.app, "d1"); + h.settle(); + type_value(&mut h, "1.3456"); + h.step(&json!({"key":"Enter"})).unwrap(); + let edited = h.app.session.world_state(); + let edited = &edited.sketch(sketch).unwrap().sketch; + assert!(edited.constraints.iter().any(|c| c.kind.value().is_some_and(|v| (v - 1.3456 * 25.4).abs() < 1e-6))); + h.app.run("edit.undo", json!({})).unwrap(); + let before = h.app.session.world_state().sketch(sketch).unwrap().sketch.clone(); + let c = before.constraints.iter().find(|c| c.param.is_some()).unwrap(); + let p = h.app.session.doc.param(c.param.as_deref().unwrap()).unwrap(); + assert_eq!(crate::dim_view::label(&c.kind, Some(&p.expr), false, "in", 2), "1.2345"); + assert!((c.kind.value().unwrap() - 31.3563).abs() < 1e-6); + h.app.run("document.units", json!({"units":"mm"})).unwrap(); + assert_eq!(h.app.session.world_state().sketch(sketch).unwrap().sketch, before); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.units, "in"); + let doc = solvecraft_engine::doc::Document::from_json(&h.app.session.doc.to_json()).unwrap(); + let reopened = solvecraft_engine::Session::new(doc); + assert_eq!(reopened.doc.units, "in"); + assert_eq!(reopened.world_state().sketch(sketch).unwrap().sketch, before); + crate::dim_view::cancel(); +} diff --git a/crates/ui-egui/src/menu_tests.rs b/crates/ui-egui/src/menu_tests.rs index 140bdcf8..5657882c 100644 --- a/crates/ui-egui/src/menu_tests.rs +++ b/crates/ui-egui/src/menu_tests.rs @@ -499,7 +499,7 @@ fn units_menu_changes_the_design_units_and_new_designs_take_the_preference() { 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")); + assert!(crate::prefs::show_mm(&app, 25.4, 1).starts_with("1.000 in")); app.preferences.default_units = "cm".into(); crate::documents::new_design(&mut app); assert_eq!(app.session.doc.units, "cm"); diff --git a/crates/ui-egui/src/occurrence_ui_tests.rs b/crates/ui-egui/src/occurrence_ui_tests.rs index 27744544..c70de7b2 100644 --- a/crates/ui-egui/src/occurrence_ui_tests.rs +++ b/crates/ui-egui/src/occurrence_ui_tests.rs @@ -202,3 +202,31 @@ fn occurrence_nested_shared_child_has_distinct_rows_and_scope() { context_menu::run_item(&mut h.app, delete, egui::Pos2::ZERO); assert!(h.app.menu.confirm.is_some(), "shared child deletion must communicate scope before removing both renderings"); } + +#[test] +fn occurrence_distance_editor_reads_inches_and_keeps_four_digits_in_model_space() { + let (mut h, _, first, _) = fixture(); + h.app.run("document.units", json!({"units":"in"})).unwrap(); + let target = Target::Occurrence { path: vec![first] }; + let items = context_menu::items(&h.app, &target); + let movement = items.iter().find(|i| i.id == "ui.moveOccurrence").unwrap(); + context_menu::run_item(&mut h.app, movement, egui::Pos2::ZERO); + h.settle(); + let original = h.app.session.doc.to_json(); + let point = h.call("ui.at", json!({"handle":"occurrence-move:distance:0"})); + let at = &point["result"]; + h.call("ui.click", json!({"x":at[0],"y":at[1],"double":true})); + h.settle(); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":"1.2345"})).unwrap(); + h.step(&json!({"key":"Enter"})).unwrap(); + let m = h.app.tree.occurrence_move.as_ref().unwrap(); + assert!((m.translate[0] - 31.3563).abs() < 1e-8, "{:?}", m.translate); + assert_eq!(h.app.session.doc.to_json(), original, "pending movement does not alter the stored design"); + h.app.session.selection = vec![Sel::Occurrence { path: vec![first] }]; + let bounds = crate::viewport::selection_bounds(&h.app).unwrap(); + assert!((bounds.min.x - 31.3563).abs() < 1e-6); + let movement = h.app.tree.occurrence_move.clone().unwrap(); + crate::browser::revert_occurrence_dialog(&mut h.app, &movement); + assert!(h.app.session.pending_moves.is_empty()); +} diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 7fa1e704..0d38e554 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -347,10 +347,49 @@ pub fn unit_scale(units: &str) -> (f64, &'static str) { } } +/// Inch labels retain a meaningful fourth digit without promoting floating-point noise. +pub fn length_precision(value: f64, units: &str, decimals: u8) -> usize { + let base = usize::from(decimals.min(8)); + if units != "in" { + return base; + } + let needs_four = ((value * 10_000.0).round() - (value * 1000.0).round() * 10.0).abs() >= 0.5; + base.max(if needs_four { 4 } else { 3 }) +} + +pub fn length_number(mm: f64, units: &str, decimals: u8) -> String { + let value = mm * unit_scale(units).0; + format!("{:.*}", length_precision(value, units, decimals), value) +} + +/// Edit a millimetre value through a control expressed in the current design units. +/// Keep the stored value untouched unless the user actually changes the field. +pub fn length_drag<'a>(value: &'a mut f64, units: &str, decimals: u8) -> egui::DragValue<'a> { + let (scale, unit) = unit_scale(units); + egui::DragValue::new(value) + .speed(0.5) + .suffix(format!(" {unit}")) + .custom_formatter(move |mm, _| length_number(mm, unit, decimals)) + .custom_parser(move |text| parse_length_mm(text, scale)) +} + +/// A length typed into a field, in millimetres: a bare number is in the design's units (`scale` +/// units per millimetre); an explicit unit ("10 mm", "0.5 in", "2 cm + 3 mm") is honoured. +pub fn parse_length_mm(text: &str, scale: f64) -> Option { + use solvecraft_engine::doc::expr; + if !(scale.is_finite() && scale > 0.0) { + return None; + } + let defaults = expr::Defaults { len: 1.0 / scale, ..expr::Defaults::default() }; + let no_params = |n: &str| Err(solvecraft_engine::doc::DocError::Expr(format!("unknown parameter `{n}`"))); + let value = expr::eval_with_defaults(text.trim(), &no_params, defaults).ok()?.to_kind_in(expr::Kind::Length, defaults).ok()?; + value.is_finite().then_some(value) +} + /// 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) + let (_, u) = unit_scale(units); + format!("{} {u}", length_number(mm, units, decimals)) } /// A length, area (`power` 2) or volume (3) given in millimetres, shown in the design's units @@ -362,13 +401,13 @@ pub fn show_mm(app: &SolveApp, v: f64, power: i32) -> String { 3 => "³", _ => "", }; - format!("{:.*} {u}{sup}", usize::from(app.preferences.length_decimals.min(8)), v * k.powi(power)) + let value = v * k.powi(power); + format!("{:.*} {u}{sup}", length_precision(value, &app.session.doc.units, app.preferences.length_decimals), value) } /// 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) + length_number(v, &app.session.doc.units, app.preferences.length_decimals) } /// Write the anti-aliasing setting where the host reads it at start (the window's samples can't @@ -787,6 +826,22 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result, b: f64| a.is_some_and(|a| (a - b).abs() < 1e-9); + assert!(close(parse_length_mm("2", inch), 50.8)); + assert!(close(parse_length_mm(" 10 mm ", inch), 10.0)); + assert!(close(parse_length_mm("0.5 in", 1.0), 12.7)); + assert!(close(parse_length_mm("1 in + 5 mm", unit_scale("cm").0), 30.4)); + assert!(close(parse_length_mm("3", unit_scale("cm").0), 30.0)); + assert!(close(parse_length_mm("3", 1.0), 3.0)); + assert_eq!(parse_length_mm("10 deg", inch), None); + assert_eq!(parse_length_mm("abc", inch), None); + assert_eq!(parse_length_mm("", inch), None); + assert_eq!(parse_length_mm("1e400", 1.0), None); + } + #[test] fn radial_context_menu_defaults_off_and_follows_preferences_transactions() { assert!(!Prefs::default().radial_context_menu); diff --git a/crates/ui-egui/src/sketch3d.rs b/crates/ui-egui/src/sketch3d.rs index 01bc9307..11496880 100644 --- a/crates/ui-egui/src/sketch3d.rs +++ b/crates/ui-egui/src/sketch3d.rs @@ -205,7 +205,7 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) - painter.text( s + vec2(10.0, 10.0), egui::Align2::LEFT_TOP, - format!("{:.2}, {:.2}, {:.2}", h.x, h.y, h.z), + format!("{}, {}, {}", crate::prefs::show_mm(app, h.x, 1), crate::prefs::show_mm(app, h.y, 1), crate::prefs::show_mm(app, h.z, 1)), egui::FontId::proportional(11.0), tk.text_dim, ); @@ -265,7 +265,12 @@ pub fn click(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> bool { TRIAD.with(|t| *t.borrow_mut() = hit.as_ref().map(|(w, i, _)| (w.clone(), *i))); if let Some((w, _, p)) = hit { app.set_status( - format!("{w} at {:.2}, {:.2}, {:.2}: drag an arrow (X, Y, Z) or the square (in the sketch plane); Delete removes it", p.x, p.y, p.z), + format!( + "{w} at {}, {}, {}: drag an arrow (X, Y, Z) or the square (in the sketch plane); Delete removes it", + crate::prefs::show_mm(app, p.x, 1), + crate::prefs::show_mm(app, p.y, 1), + crate::prefs::show_mm(app, p.z, 1) + ), false, ); return true; diff --git a/crates/ui-egui/src/sketch_dims.rs b/crates/ui-egui/src/sketch_dims.rs index a2aa4965..12647baf 100644 --- a/crates/ui-egui/src/sketch_dims.rs +++ b/crates/ui-egui/src/sketch_dims.rs @@ -170,11 +170,11 @@ fn live(t: &Tool, e: Vec2, px: f64) -> Vec<(f64, Vec2)> { } } -fn fmt(kind: ValueKind, v: f64) -> String { +fn fmt(app: &SolveApp, kind: ValueKind, v: f64) -> String { match kind { ValueKind::Angle => format!("{:.1} deg", v.to_degrees()), ValueKind::Unitless => format!("{}", v.round()), - ValueKind::Length => format!("{v:.2}"), + ValueKind::Length => crate::prefs::show_mm_bare(app, v), } } @@ -310,7 +310,7 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option for (i, b) in t.dims.iter_mut().enumerate() { let Some((v, at)) = lv.get(i).copied() else { continue }; if !b.locked { - b.value = fmt(b.kind, v); + b.value = fmt(app, b.kind, v); } let Some(sp) = proj.to_screen(plane.to_world(at)) else { continue }; let id = egui::Id::new(("sc_dim", i)); diff --git a/crates/ui-egui/src/sketch_dims_tests.rs b/crates/ui-egui/src/sketch_dims_tests.rs index 75c3dfb7..33b9b334 100644 --- a/crates/ui-egui/src/sketch_dims_tests.rs +++ b/crates/ui-egui/src/sketch_dims_tests.rs @@ -126,3 +126,24 @@ fn forward_tab_completes_expression_then_cycles_and_shift_tab_moves_back() { assert_eq!(sketch.profiles.len(), 1); assert!((sketch.profiles[0].area - 120.0).abs() < 1e-6); } + +#[test] +fn inch_live_lengths_use_design_units_and_typed_four_digits_commit_in_inches() { + let mut h = Harness::new(); + h.app.run("document.units", json!({"units":"in"})).unwrap(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.step(&json!({"start":"sketch.rectangle.two_point"})).unwrap(); + h.step(&json!({"click":{"world":[0,0,0]}})).unwrap(); + h.step(&json!({"move":{"world":[25.4,12.7,0]}})).unwrap(); + let dims = &h.app.tool.as_ref().unwrap().dims; + // Pointer snapping rounds in model space; displayed values must still be inches. + assert!((dims[0].value.parse::().unwrap() - 1.0).abs() < 0.01); + assert!((dims[1].value.parse::().unwrap() - 0.5).abs() < 0.01); + type_value(&mut h, "1.2345"); + tab(&mut h, false); + type_value(&mut h, "0.500"); + h.step(&json!({"key":"Enter"})).unwrap(); + let st = h.app.session.world_state(); + let sk = &st.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch; + assert!(sk.constraints.iter().any(|c| c.kind.value().is_some_and(|v| (v - 31.3563).abs() < 1e-6))); +} diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 61de6b4b..96d079c3 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -75,7 +75,7 @@ fn analysis(app: &mut SolveApp, cmd: &str, p: Value) { let Ok(v) = app.run(cmd, p) else { return }; let mark = match cmd { "inspect.center_of_mass" => v3(&v["center"]).map(|c| (c, "Center of mass".to_string())), - _ => v3(&v["at"]).zip(v["min_radius"].as_f64()).map(|(c, r)| (c, format!("R min {r:.3} mm"))), + _ => v3(&v["at"]).zip(v["min_radius"].as_f64()).map(|(c, r)| (c, format!("R min {}", crate::prefs::show_mm(app, r, 1)))), }; if let Some(m) = mark { MARKS.with(|k| k.borrow_mut().push(m)); diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 5d56ea97..3561281e 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -579,7 +579,7 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { painter.text( sp + egui::vec2(10.0, 10.0), egui::Align2::LEFT_TOP, - format!("{:.2}, {:.2}", hp.x, hp.y), + format!("{}, {}", crate::prefs::show_mm(app, hp.x, 1), crate::prefs::show_mm(app, hp.y, 1)), egui::FontId::proportional(11.0), tk.text_dim, ); diff --git a/crates/ui-egui/tests/scenarios/s19_canvas_calibrate.json b/crates/ui-egui/tests/scenarios/s19_canvas_calibrate.json index 891ab717..fe308d70 100644 --- a/crates/ui-egui/tests/scenarios/s19_canvas_calibrate.json +++ b/crates/ui-egui/tests/scenarios/s19_canvas_calibrate.json @@ -42,7 +42,7 @@ } }, { - "widget": "100.0 mm", + "widget": "100.00 mm", "near": "Distance" }, { diff --git a/crates/ui-egui/tests/scenarios/s34_component_move_carries_sketches.json b/crates/ui-egui/tests/scenarios/s34_component_move_carries_sketches.json index 7c2db2ac..6f1feac6 100644 --- a/crates/ui-egui/tests/scenarios/s34_component_move_carries_sketches.json +++ b/crates/ui-egui/tests/scenarios/s34_component_move_carries_sketches.json @@ -137,7 +137,7 @@ } }, { - "widget": "0.0 mm", + "widget": "0.00 mm", "near": "X distance" }, { diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index 3f83cc45..068b22f7 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -132,3 +132,16 @@ when a parameter moves the geometry. Islands inside the region are its holes. Wh more than one (both halves of a circle cut by a line), is bounded by exactly those curves, the feature reports an error rather than taking another region. Profiles picked by index or by point (`[{"point": [x, y]}]`) are stored the same way when the feature is made. + +## Measurement units + +Change the current design through **Document Settings > Units** in the browser. +The units preference applies to new designs. Existing sketch annotations, live drawing +lengths and Measure follow the current design units without resizing geometry. +Inches display at least three decimal places and retain a meaningful fourth digit; +a higher display precision can be selected in Preferences. Bare length values typed +in the dimension tool use the current design units. Explicit units such as `25.4 mm` +remain valid in an inch design. Angular dimensions keep their angular units. Deliberately typed four-place inch literals +retain trailing zeros. Component/canvas distance controls and inspection, parts-library +and flat-pattern measurements follow design units too; parameter expressions and metric +standard names retain their explicit authored units. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 416e072b..36415fab 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -37,6 +37,12 @@ rolls the numbering back with the design), so they can go straight into later co {"id":8,"method":"engine.execute","params":{"command":"sketch.dimension","params":{"entities":["l1"],"value":50}}} ``` +A bare numeric `value` given to `sketch.dimension` (from the control channel, scripts or MCP) is +read in the design's units, exactly as if it were typed into the dimension box: in an inch design +`"value": 2` makes the line 2 in (50.8 mm) long. Point coordinates stay in millimetres. To pin a +unit regardless of the design, pass an expression with one: `"value": "50 mm"` or +`"value": "0.5 in"`. Angles are degrees unless the expression says otherwise. + ## UI | Method | Params | Notes |