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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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

+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,
);