Show optional X/Y/Z distances in measurement overlays (#1422)

Measure can show the world-axis components alongside the total distance, with a Graphics preference to turn them off.

Co-authored-by: cronos <19868002+crono2250@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-15 19:09:26 -04:00
1 parent 3d914c9f15
commit 96d1ad0ab2
14 files changed
+272 -2

No files matched your search

+2
View File
@@ -1,4 +1,6 @@
{
"settings.measure_axis_distances": "X/Y/Z-Maße anzeigen",
"settings.measure_axis_distances_help": "Bei geöffneter Messung die Abstände entlang der Weltachsen zusätzlich zum Gesamtabstand anzeigen.",
"Send your report on GitHub": "Bericht auf GitHub senden",
"1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. Unten den GitHub-Entwurf öffnen und den kopierten Bericht aus der Zwischenablage einfügen.",
"1. Open GitHub draft below. Your title and report will be filled in.": "1. Unten den GitHub-Entwurf öffnen. Titel und Bericht werden vorausgefüllt.",
+2
View File
@@ -1,4 +1,6 @@
{
"settings.measure_axis_distances": "Show X/Y/Z distances",
"settings.measure_axis_distances_help": "Show world-axis component dimensions alongside the total distance while Measure is open.",
"Send your report on GitHub": "Send your report on GitHub",
"1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. Open GitHub draft below, then paste the report copied to your clipboard.",
"1. Open GitHub draft below. Your title and report will be filled in.": "1. Open GitHub draft below. Your title and report will be filled in.",
+2
View File
@@ -1,4 +1,6 @@
{
"settings.measure_axis_distances": "Afficher les cotes X/Y/Z",
"settings.measure_axis_distances_help": "Afficher les cotes suivant les axes du repère global en plus de la distance totale pendant la mesure.",
"Send your report on GitHub": "Envoyer votre rapport sur GitHub",
"1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. Ouvrir le brouillon GitHub ci-dessous, puis coller le rapport copié dans le presse-papiers.",
"1. Open GitHub draft below. Your title and report will be filled in.": "1. Ouvrir le brouillon GitHub ci-dessous. Votre titre et votre rapport y seront déjà remplis.",
+2
View File
@@ -1,4 +1,6 @@
{
"settings.measure_axis_distances": "計測のX・Y・Z距離を表示",
"settings.measure_axis_distances_help": "計測ダイアログを開いている間、2点間距離に加えてワールドX・Y・Z軸方向の寸法線と距離を表示します。",
"Send your report on GitHub": "GitHubでレポートを送信",
"1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. 下のボタンでGitHubの下書きを開き、クリップボードにコピーされたレポートを貼り付けます。",
"1. Open GitHub draft below. Your title and report will be filled in.": "1. 下のボタンでGitHubの下書きを開きます。タイトルとレポートは入力済みです。",
+2
View File
@@ -1,4 +1,6 @@
{
"settings.measure_axis_distances": "X/Y/Z 거리 표시",
"settings.measure_axis_distances_help": "측정 대화상자가 열려 있는 동안 전체 거리와 함께 월드 축 방향 치수와 거리를 표시합니다.",
"Send your report on GitHub": "GitHub에서 보고서 보내기",
"1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. 아래에서 GitHub 초안을 열고 클립보드에 복사된 보고서를 붙여넣으세요.",
"1. Open GitHub draft below. Your title and report will be filled in.": "1. 아래에서 GitHub 초안을 여세요. 제목과 보고서가 자동으로 입력됩니다.",
+2
View File
@@ -1,4 +1,6 @@
{
"settings.measure_axis_distances": "Расстояния X/Y/Z",
"settings.measure_axis_distances_help": "Показывать размеры вдоль мировых осей вместе с общим расстоянием, пока открыто окно измерения.",
"browser.body_name": "Тело{number}",
"browser.component_name": "Компонент{number}",
"browser.parameters": "Параметры ({count})",
+2
View File
@@ -1,4 +1,6 @@
{
"settings.measure_axis_distances": "Відстані X/Y/Z",
"settings.measure_axis_distances_help": "Показувати розміри вздовж світових осей разом із загальною відстанню, поки відкрите вікно вимірювання.",
"Send your report on GitHub": "Надіслати звіт на GitHub",
"1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. Відкрийте чернетку GitHub нижче, а потім вставте звіт, скопійований у буфер обміну.",
"1. Open GitHub draft below. Your title and report will be filled in.": "1. Відкрийте чернетку GitHub нижче. Заголовок і звіт буде заповнено.",
+2
View File
@@ -1,4 +1,6 @@
{
"settings.measure_axis_distances": "显示 X/Y/Z 距离",
"settings.measure_axis_distances_help": "测量对话框打开时,在总距离之外显示沿世界坐标轴方向的尺寸线和距离。",
"Send your report on GitHub": "在 GitHub 上发送报告",
"1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. 点击下方按钮打开 GitHub 草稿,然后粘贴已复制到剪贴板的报告。",
"1. Open GitHub draft below. Your title and report will be filled in.": "1. 点击下方按钮打开 GitHub 草稿。标题和报告将自动填入。",
+76
View File
@@ -769,7 +769,13 @@ fn radius_spline_handles(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Pain
fn measure_line(app: &SolveApp, painter: &egui::Painter, proj: &Proj, r: &serde_json::Value, show: Option<crate::dialogs::Shown>) {
let t = Tokens::get();
let Some((_, d, a, b)) = crate::dialogs::shown_distance(r, show) else { return };
if !a.is_finite() || !b.is_finite() || !d.is_finite() {
return;
}
let (Some(sa), Some(sb)) = (proj.to_screen(a), proj.to_screen(b)) else { return };
if !sa.is_finite() || !sb.is_finite() {
return;
}
painter.line_segment([sa, sb], Stroke::new(2.0, t.manipulator));
for s in [sa, sb] {
painter.circle(s, 3.5, t.manipulator, Stroke::new(1.0, Color32::WHITE));
@@ -777,10 +783,80 @@ fn measure_line(app: &SolveApp, painter: &egui::Painter, proj: &Proj, r: &serde_
let mid = sa + (sb - sa) * 0.5 + vec2(10.0, -14.0);
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));
crate::scenario::publish_handle("measure:label", rect.center());
crate::scenario::publish_rect("measure:label", rect);
if app.preferences.measure_axis_distances {
measure_components(app, painter, proj, a, b, rect);
}
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);
}
/// A world-axis-aligned chain between the same two measured points. Zero-length and
/// view-aligned legs still get a label; labels move apart when their projections coincide.
fn measure_components(app: &SolveApp, painter: &egui::Painter, proj: &Proj, a: Vec3, b: Vec3, total: egui::Rect) {
let t = Tokens::get();
let x = Vec3::new(b.x, a.y, a.z);
let y = Vec3::new(b.x, b.y, a.z);
let mut occupied = vec![total];
for (axis, start, end, color) in [("x", a, x, t.axis_x), ("y", x, y, t.axis_y), ("z", y, b, t.axis_z)] {
let color = color.lerp_to_gamma(t.text, 0.25);
let distance = start.dist(end);
if !distance.is_finite() {
continue;
}
let screen =
proj.to_screen(start).zip(proj.to_screen(end)).filter(|(sa, sb)| sa.is_finite() && sb.is_finite() && (*sb - *sa).length_sq().is_finite());
let (at, offset) = if let Some((sa, sb)) = screen {
let leg = sb - sa;
if leg.length_sq() > 1.0 {
painter.add(egui::Shape::dashed_line(&[sa, sb], Stroke::new(1.5, color), 5.0, 3.0));
let normal = vec2(-leg.y, leg.x).normalized();
for p in [sa, sb] {
painter.line_segment([p - normal * 3.0, p + normal * 3.0], Stroke::new(1.0, color));
}
(sa + leg * 0.5, normal * 20.0)
} else {
(sa, vec2(20.0, 20.0))
}
} else {
(total.center(), vec2(20.0, 20.0))
};
let text = format!("{}: {}", axis.to_uppercase(), crate::prefs::show_mm(app, distance, 1));
let galley = painter.layout_no_wrap(text, egui::FontId::proportional(12.5), t.text);
let size = galley.size() + vec2(10.0, 6.0);
let bounds = proj.rect.shrink(4.0);
let clamp = |rect: egui::Rect| {
egui::Rect::from_min_size(
egui::pos2(
rect.left().clamp(bounds.left(), (bounds.right() - size.x).max(bounds.left())),
rect.top().clamp(bounds.top(), (bounds.bottom() - size.y).max(bounds.top())),
),
size,
)
};
let preferred = clamp(egui::Rect::from_center_size(at + offset, size));
let mut rect = preferred;
// Four labels at most. Search both sides of the anchor, including at viewport edges.
'place: for row in 0..12 {
for side in [1.0, -1.0] {
let candidate = clamp(preferred.translate(vec2(0.0, side * row as f32 * (size.y + 4.0))));
if occupied.iter().all(|other| !other.expand(3.0).intersects(candidate)) {
rect = candidate;
break 'place;
}
}
}
painter.line_segment([at, rect.center()], Stroke::new(0.8, color));
painter.rect(rect, 3.0, t.overlay, Stroke::new(1.0, color), egui::StrokeKind::Inside);
painter.galley(rect.min + vec2(5.0, 3.0), galley, t.text);
let handle = format!("measure:label:{axis}");
crate::scenario::publish_handle(&handle, rect.center());
crate::scenario::publish_rect(&handle, rect);
occupied.push(rect);
}
}
/// The taper handle: a line from `foot` along the extrude (`span`) leaning by `angle` towards
/// `e`, with a ring at its end; dragging the ring sideways sets the angle (1° steps, Alt/Ctrl:
/// free).
+2
View File
@@ -63,6 +63,8 @@ pub mod issue_report;
pub mod keymap;
mod material_card_review;
#[cfg(test)]
mod measurement_ui_tests;
#[cfg(test)]
mod menu_tests;
#[cfg(test)]
mod surface_clamp_tests;
+153
View File
@@ -0,0 +1,153 @@
//! World-axis component dimensions use the same endpoints as the displayed measurement.
use crate::dialogs::Kind;
use crate::scenario::Harness;
use serde_json::{Value, json};
use solidcraft_engine::Sel;
fn result(h: &Harness) -> &Value {
match &h.app.dialog.as_ref().unwrap().kind {
Kind::Measure { result: Some(r), .. } => r,
_ => panic!("expected a measurement result"),
}
}
fn vertex_measurement(a: [f64; 3], b: [f64; 3]) -> Harness {
let mut h = Harness::new();
h.app.home.open = false;
h.app.ui.show_origin = false;
h.app.ui.show_browser = false;
h.app.run("solid.box", json!({"length":3,"width":4,"height":12,"corner":[-3,0,-12],"body_name":"Box"})).unwrap();
h.app.session.selection = [a, b].into_iter().map(|p| Sel::Vertex { body: "Box".into(), point: p.into() }).collect();
h.app.start("inspect.measure");
h.call("ui.view", json!({"view":"fit"}));
h
}
/// Inspect the actual painted text, including the viewport labels (which are not widgets).
fn painted_text(h: &mut Harness) -> Vec<String> {
// Continue the harness clock (a hair on) so egui's input history never sees time go backwards.
let time = h.ctx.input(|i| i.time) + 1e-4;
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))),
time: Some(time),
..Default::default()
};
let mut output = h.ctx.run_ui(raw, |ui| h.app.ui(ui));
output.textures_delta.clear();
output.shapes.iter().filter_map(|s| if let egui::Shape::Text(t) = &s.shape { Some(t.galley.text().to_string()) } else { None }).collect()
}
fn component_rects() -> Vec<egui::Rect> {
["measure:label", "measure:label:x", "measure:label:y", "measure:label:z"].into_iter().map(|key| crate::scenario::rect_of(key).unwrap()).collect()
}
#[test]
fn measurement_axis_distances_follow_the_upstream_centre_min_and_max_choices() {
let mut h = Harness::new();
h.app.home.open = false;
h.app.ui.show_origin = false;
h.app.ui.show_browser = false;
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
let mut items = Vec::new();
for (center, radius) in [([0, 0], 3), ([30, 40], 5)] {
let made = h.app.run("sketch.circle.center", json!({"center":center,"radius":radius})).unwrap();
items.push(Sel::SketchCurve { id: made["curves"][0].as_str().unwrap().into() });
}
h.app.session.selection = items;
h.app.start("inspect.measure");
h.call("ui.view", json!({"view":"fit"}));
let measured = result(&h).clone();
assert!((measured["center_distance"].as_f64().unwrap() - 50.).abs() < 1e-8);
assert!(measured["distance_mm"].as_f64().unwrap() < 50. && measured["max"].as_f64().unwrap() > 50.);
for (label, distance, from, to) in [
("Centre to centre", "center_distance", "center_from", "center_to"),
("Min", "distance_mm", "from", "to"),
("Max", "max", "max_from", "max_to"),
] {
let [x, y] = h.widget(label).unwrap_or_else(|| panic!("missing upstream distance choice: {label}"));
h.call("ui.click", json!({"x":x,"y":y}));
let texts = painted_text(&mut h);
for (axis, index) in [("X", 0), ("Y", 1), ("Z", 2)] {
let delta = measured[to][index].as_f64().unwrap() - measured[from][index].as_f64().unwrap();
let expected = format!("{axis}: {}", crate::prefs::show_mm(&h.app, delta.abs(), 1));
assert!(texts.contains(&expected), "{label} must use its own endpoints: {texts:?}");
}
assert!(texts.contains(&crate::prefs::show_mm(&h.app, measured[distance].as_f64().unwrap(), 1)));
assert_eq!(*result(&h), measured, "changing the displayed distance must retain upstream measurement results");
}
}
#[test]
fn measurement_axis_distances_show_absolute_world_components_in_every_view() {
for reverse in [false, true] {
let (a, b) = ([0., 0., 0.], [-3., 4., -12.]);
let mut h = if reverse { vertex_measurement(b, a) } else { vertex_measurement(a, b) };
assert!((result(&h)["distance_mm"].as_f64().unwrap() - 13.).abs() < 1e-8);
for view in ["iso", "top", "front", "right"] {
h.call("ui.view", json!({"view":view}));
let texts = painted_text(&mut h);
for text in ["13.00 mm", "X: 3.00 mm", "Y: 4.00 mm", "Z: 12.00 mm"] {
assert!(texts.iter().any(|t| t == text), "{view}, reverse={reverse}: {texts:?}");
}
let rects = component_rects();
for (i, a) in rects.iter().enumerate() {
assert!(rects.iter().skip(i + 1).all(|b| !a.intersects(*b)), "labels overlap in {view}: {rects:?}");
}
}
h.call("ui.set", json!({"perspective":true}));
h.call("ui.prefs", json!({"up_axis":"y"}));
let texts = painted_text(&mut h);
assert!(texts.contains(&"Z: 12.00 mm".to_string()), "view orientation does not change world-axis distances");
h.call("ui.key", json!({"key":"Escape"}));
assert!(crate::scenario::handles("measure:label").is_empty(), "all temporary dimensions disappear on close");
}
}
#[test]
fn measurement_axis_distances_keep_zero_components_readable_and_follow_units() {
let mut h = vertex_measurement([0., 0., 0.], [-3., 0., 0.]);
h.call("ui.view", json!({"view":"top"}));
let texts = painted_text(&mut h);
assert!(texts.contains(&"Y: 0.00 mm".into()) && texts.contains(&"Z: 0.00 mm".into()));
let rects = component_rects();
for (i, a) in rects.iter().enumerate() {
assert!(rects.iter().skip(i + 1).all(|b| !a.intersects(*b)), "zero-component labels must remain readable: {rects:?}");
}
h.app.run("document.units", json!({"units":"in"})).unwrap();
h.call("ui.prefs", json!({"length_decimals":3}));
let texts = painted_text(&mut h);
assert!(texts.contains(&"X: 0.1181 in".into()), "inch dimensions retain a meaningful fourth digit: {texts:?}");
assert!(texts.contains(&"Y: 0.000 in".into()) && texts.contains(&"Z: 0.000 in".into()));
}
#[test]
fn measurement_axis_distances_can_be_disabled_saved_and_restored_in_preferences() {
assert!(serde_json::from_value::<crate::prefs::Prefs>(json!({})).unwrap().measure_axis_distances, "existing settings get the default-on option");
let mut h = vertex_measurement([0., 0., 0.], [-3., 4., -12.]);
let measurement = result(&h).clone();
h.call("ui.prefs", json!({"open":"Graphics"}));
let checkbox = crate::scenario::rect_of("prefs:measure_axis_distances").unwrap().center();
h.call("ui.click", json!({"x":checkbox.x,"y":checkbox.y}));
assert!(!h.app.preferences.measure_axis_distances);
assert!(crate::scenario::handle_at("measure:label").is_some(), "total stays visible");
assert!(crate::scenario::handle_at("measure:label:x").is_none());
assert_eq!(*result(&h), measurement, "hiding the extra overlay does not change the measurement");
h.call("ui.prefs", json!({"button":"ok"}));
let texts = painted_text(&mut h);
assert!(!texts.iter().any(|text| text.starts_with("X: ") || text.starts_with("Y: ") || text.starts_with("Z: ")));
let mut restored = crate::SolveApp::new(Default::default(), Default::default());
restored.load_prefs(&h.app.prefs());
assert!(!restored.preferences.measure_axis_distances, "disabled preference survives restart");
h.call("ui.prefs", json!({"open":"Graphics","measure_axis_distances":true}));
assert!(crate::scenario::handle_at("measure:label:x").is_some());
h.call("ui.prefs", json!({"button":"cancel"}));
assert!(!h.app.preferences.measure_axis_distances, "Cancel restores the saved choice");
h.call("ui.prefs", json!({"open":"Graphics"}));
h.call("ui.prefs", json!({"button":"defaults"}));
assert!(h.app.preferences.measure_axis_distances);
h.call("ui.prefs", json!({"button":"apply"}));
h.call("ui.prefs", json!({"measure_axis_distances":false}));
h.call("ui.prefs", json!({"button":"cancel"}));
assert!(h.app.preferences.measure_axis_distances, "Cancel restores the last Apply");
}
+9 -2
View File
@@ -283,6 +283,8 @@ pub struct Prefs {
pub supersampling: f32,
/// Supersample only while the view holds still (full speed while orbiting).
pub supersampling_adaptive: bool,
/// Show world X/Y/Z component dimensions beside the total distance in Measure.
pub measure_axis_distances: bool,
/// Decimals shown for lengths and angles.
pub length_decimals: u8,
pub angle_decimals: u8,
@@ -396,6 +398,7 @@ impl Default for Prefs {
display_quality: "normal".into(),
supersampling: 1.0,
supersampling_adaptive: true,
measure_axis_distances: true,
length_decimals: 2,
angle_decimals: 1,
theme: "dark".into(),
@@ -618,6 +621,7 @@ fn defaults(app: &mut SolveApp, page: usize) {
p.display_quality = d.display_quality;
p.supersampling = d.supersampling;
p.supersampling_adaptive = d.supersampling_adaptive;
p.measure_axis_distances = d.measure_axis_distances;
p.color_vision = d.color_vision;
app.ui.visual_style = 0;
app.ui.show_grid = true;
@@ -1278,9 +1282,12 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
_ => "CPU (software)".to_string(),
});
ui.end_row();
// Colour vision (#778): its own section at the end of the page.
ui.label("");
ui.label(app.tr("settings.measure_axis_distances"));
let response = ui.checkbox(&mut app.preferences.measure_axis_distances, "");
crate::scenario::publish_rect("prefs:measure_axis_distances", response.rect);
response.on_hover_text(app.tr("settings.measure_axis_distances_help"));
ui.end_row();
// Colour vision (#778): its own section at the end of the page.
ui.label(RichText::new(app.tr("ui.colour_vision")).strong());
ui.label("");
ui.end_row();
+5
View File
@@ -243,6 +243,11 @@ retain trailing zeros. Component/canvas distance controls and inspection, parts-
and flat-pattern measurements follow design units too; parameter expressions and metric
standard names retain their explicit authored units.
Measure shows the currently selected distance (centre, minimum or maximum) and separate X, Y and Z
distances on the canvas. The component dimensions follow the world axes and remain visible
until the Measure dialog closes. To hide these extra lines and labels, turn off
**Preferences > Graphics > Show X/Y/Z distances**. The choice is saved between runs.
### Reviewing mirror, pattern, and offset results
Mirror, rectangular/circular Pattern, and Offset show the active selection step in their
+11
View File
@@ -97,6 +97,17 @@ read in the design's units, exactly as if it were typed into the dimension box:
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.
The viewport also shows an X/Y/Z-aligned dimension chain between the endpoints of the
currently shown distance (centre, minimum or maximum), with each world-axis distance
labelled separately as an absolute value (including zero).
It uses the design's units and length precision, independently of the view or sketch plane.
Preferences → Graphics → **Show X/Y/Z distances** controls these extra lines and
labels (default on, saved between runs). Set `ui.prefs {"measure_axis_distances": false}`
to hide them while keeping the total distance and the dialog's ΔX/ΔY/ΔZ values. These
overlays remain visible until Measure closes.
Overlay handles `measure:label:x`, `measure:label:y` and `measure:label:z` identify the extra
labels; `prefs:measure_axis_distances` identifies the checkbox.
## UI
| Method | Params | Notes |