diff --git a/assets/locales/de.json b/assets/locales/de.json index 0d0c6524..42755ebd 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -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.", diff --git a/assets/locales/en.json b/assets/locales/en.json index 18d234ca..ac454dc7 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -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.", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index 182b8a9c..2cfea4c4 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -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.", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 29bb48ed..f71689d7 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -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の下書きを開きます。タイトルとレポートは入力済みです。", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index ea74c84b..758975dd 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -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 초안을 여세요. 제목과 보고서가 자동으로 입력됩니다.", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 964efd54..70822a6a 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -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})", diff --git a/assets/locales/uk.json b/assets/locales/uk.json index 449050bb..fd32c912 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -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 нижче. Заголовок і звіт буде заповнено.", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index bea389b1..4d3a0aa7 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -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 草稿。标题和报告将自动填入。", diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 22641c89..95929af3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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) { 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). diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8311be00..b0026ec6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/measurement_ui_tests.rs b/crates/ui-egui/src/measurement_ui_tests.rs new file mode 100644 index 00000000..12ca2ce8 --- /dev/null +++ b/crates/ui-egui/src/measurement_ui_tests.rs @@ -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 { + // 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 { + ["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::(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"); +} diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 6498e549..6a81f899 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -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(); diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index 3affeb01..3ca3c4ef 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -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 diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 5c5a6e92..eb2360fd 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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 |