Honor design units in sketch dimensions and measurements (#241)

* Honor design units in sketch dimensions and measurements

* fix: apply document units to specialized length controls

* Accept explicit units in length fields; document design-unit dimension values

Length fields in a design-unit control now evaluate what is typed as a
length expression: a bare number stays in the design's units, while an
explicit unit ("10 mm", "0.5 in", "1 in + 5 mm") is converted.
Document that a bare numeric sketch.dimension value from scripts, the
control channel or MCP is read in the design's units.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: bherbruck <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-12 10:08:11 -04:00
1 parent df09fdbc32
commit 62847173f4
23 files changed
+285 -71

No files matched your search

+5
View File
@@ -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.
+2 -2
View File
@@ -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<Value> {
}
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();
+13 -9
View File
@@ -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);
+4 -4
View File
@@ -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<Vec3> {
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);
+9 -9
View File
@@ -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);
+3 -3
View File
@@ -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<Mesh>) {
(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();
}
}
+2 -2
View File
@@ -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();
}
}
+10 -10
View File
@@ -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<String> {
fn pocket_label(app: &SolveApp, pocket: &Value) -> Option<String> {
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<String> {
"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)]
+14 -6
View File
@@ -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());
}
+47 -11
View File
@@ -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::<f64>().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::<f64>().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°");
}
}
@@ -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();
}
+1 -1
View File
@@ -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");
+28
View File
@@ -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());
}
+60 -5
View File
@@ -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<f64> {
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<serde_json::Valu
mod tests {
use super::*;
#[test]
fn length_fields_read_bare_numbers_in_design_units_and_honour_explicit_units() {
let inch = unit_scale("in").0;
let close = |a: Option<f64>, 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);
+7 -2
View File
@@ -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;
+3 -3
View File
@@ -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));
+21
View File
@@ -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::<f64>().unwrap() - 1.0).abs() < 0.01);
assert!((dims[1].value.parse::<f64>().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)));
}
+1 -1
View File
@@ -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));
+1 -1
View File
@@ -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,
);
@@ -42,7 +42,7 @@
}
},
{
"widget": "100.0 mm",
"widget": "100.00 mm",
"near": "Distance"
},
{
@@ -137,7 +137,7 @@
}
},
{
"widget": "0.0 mm",
"widget": "0.00 mm",
"near": "X distance"
},
{
+13
View File
@@ -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.
+6
View File
@@ -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 |