From d5f4c2cf03f54913062c6046662266f90fcbf134 Mon Sep 17 00:00:00 2001 From: Jairo Olaya Date: Fri, 13 Nov 2026 15:37:51 -0500 Subject: [PATCH 1/2] Implement the measure boxes on the operations --- assets/i18n/en_US.json | 6 +- assets/i18n/es_419.json | 6 +- assets/i18n/pt_BR.json | 6 +- assets/i18n/vi_VN.json | 6 +- assets/i18n/zh_CN.json | 6 +- crates/benchup-app/src/app.rs | 78 +++- crates/benchup-app/src/app_tests.rs | 14 + crates/benchup-app/src/arc_tool_tests.rs | 35 ++ crates/benchup-app/src/dimension_overlay.rs | 393 ++++++++++++++++++ crates/benchup-app/src/precision.rs | 17 +- crates/benchup-app/src/precision_tests.rs | 160 ++++++- .../benchup-app/src/push_pull_tool_tests.rs | 16 +- crates/benchup-app/src/sidebar.rs | 23 + crates/benchup-core/src/display.rs | 3 + 14 files changed, 739 insertions(+), 30 deletions(-) create mode 100644 crates/benchup-app/src/dimension_overlay.rs diff --git a/assets/i18n/en_US.json b/assets/i18n/en_US.json index 996f579..d53a9f0 100644 --- a/assets/i18n/en_US.json +++ b/assets/i18n/en_US.json @@ -64,7 +64,7 @@ "app.click_a_filled_face_first": "Click a filled face first", "app.click_a_reference_point_away_from_the_pivot": "Click a reference point away from the pivot first", "app.click_a_reference_point_away_from_the_pivot_2": "Click a reference point away from the pivot · Esc to cancel", - "app.click_the_opposite_corner_type_width_height_5m": "Click the opposite corner · Type width,height (5m,50mm) + Enter", + "app.click_the_opposite_corner_type_width_height_5m": "Click the opposite corner · Type width, Tab for height (or 5m,50mm) + Enter", "app.click_to_confirm_esc_to_cancel": "{} · Click to confirm · Esc to cancel", "app.click_to_confirm_esc_to_cancel_2": "{value:.3} × · Click to confirm · Esc to cancel", "app.click_to_confirm_esc_to_cancel_3": "{}{}{alignment} · Click to confirm · Esc to cancel", @@ -261,6 +261,9 @@ "app.wavefront_obj_obj": "Wavefront OBJ (.obj)…", "app.wavefront_obj_obj_metres": "Wavefront OBJ (.obj; metres)…", "app.width_height": "Width, height", + "app.push_pull_total": "Total: {total}", + "app.width": "Width", + "app.height": "Height", "app.wood_joinery": "Wood Joinery", "app.your_model_stays_on_this_device": "Your model stays on this device", "app.your_work_is_saved_automatically_on_this_device": "Your work is saved automatically on this device. Save a .bup project to keep a portable copy. Legacy .bup files are also supported.", @@ -2182,5 +2185,6 @@ "app.calculating_measurements": "Calculating measurements…", "sidebar.move_wireframe": "Wireframe other objects while moving", "sidebar.selected_edge_lengths": "Selected Edge Lengths", + "sidebar.floating_dimension_input": "Floating Dimension Input", "cli.agent_edit": "Agent edit" } diff --git a/assets/i18n/es_419.json b/assets/i18n/es_419.json index ee5d550..d49f6c7 100644 --- a/assets/i18n/es_419.json +++ b/assets/i18n/es_419.json @@ -64,7 +64,7 @@ "app.click_a_filled_face_first": "Haz clic primero en una cara rellena", "app.click_a_reference_point_away_from_the_pivot": "Haz clic primero en un punto de referencia alejado del pivote", "app.click_a_reference_point_away_from_the_pivot_2": "Haz clic en un punto de referencia alejado del pivote · Esc para cancelar", - "app.click_the_opposite_corner_type_width_height_5m": "Haz clic en la esquina opuesta · Escribe ancho,alto (5m,50mm) + Enter", + "app.click_the_opposite_corner_type_width_height_5m": "Haz clic en la esquina opuesta · Escribe el ancho, Tab para el alto (o 5m,50mm) + Enter", "app.click_to_confirm_esc_to_cancel": "{} · Haz clic para confirmar · Esc para cancelar", "app.click_to_confirm_esc_to_cancel_2": "{value:.3} × · Haz clic para confirmar · Esc para cancelar", "app.click_to_confirm_esc_to_cancel_3": "{}{}{alignment} · Haz clic para confirmar · Esc para cancelar", @@ -261,6 +261,9 @@ "app.wavefront_obj_obj": "Wavefront OBJ (.obj)…", "app.wavefront_obj_obj_metres": "Wavefront OBJ (.obj; metros)…", "app.width_height": "Ancho, alto", + "app.push_pull_total": "Total: {total}", + "app.width": "Ancho", + "app.height": "Alto", "app.wood_joinery": "Ensambles de madera", "app.your_model_stays_on_this_device": "Tu modelo permanece en este dispositivo", "app.your_work_is_saved_automatically_on_this_device": "Tu trabajo se guarda automáticamente en este dispositivo. Guarda un proyecto .bup para conservar una copia portátil. También se admiten archivos .bup antiguos.", @@ -2182,5 +2185,6 @@ "app.calculating_measurements": "Calculando mediciones…", "sidebar.move_wireframe": "Estructura alámbrica de otros objetos al mover", "sidebar.selected_edge_lengths": "Longitudes de aristas seleccionadas", + "sidebar.floating_dimension_input": "Cuadro de medidas flotante", "cli.agent_edit": "Edición del agente" } diff --git a/assets/i18n/pt_BR.json b/assets/i18n/pt_BR.json index 9f33f37..cbfabe7 100644 --- a/assets/i18n/pt_BR.json +++ b/assets/i18n/pt_BR.json @@ -64,7 +64,7 @@ "app.click_a_filled_face_first": "Clique primeiro em uma face preenchida", "app.click_a_reference_point_away_from_the_pivot": "Clique primeiro em um ponto de referência afastado do pivô", "app.click_a_reference_point_away_from_the_pivot_2": "Clique em um ponto de referência afastado do pivô · Esc para cancelar", - "app.click_the_opposite_corner_type_width_height_5m": "Clique no canto oposto · Digite largura,altura (5m,50mm) + Enter", + "app.click_the_opposite_corner_type_width_height_5m": "Clique no canto oposto · Digite a largura, Tab para a altura (ou 5m,50mm) + Enter", "app.click_to_confirm_esc_to_cancel": "{} · Clique para confirmar · Esc para cancelar", "app.click_to_confirm_esc_to_cancel_2": "{value:.3} × · Clique para confirmar · Esc para cancelar", "app.click_to_confirm_esc_to_cancel_3": "{}{}{alignment} · Clique para confirmar · Esc para cancelar", @@ -261,6 +261,9 @@ "app.wavefront_obj_obj": "Wavefront OBJ (.obj)…", "app.wavefront_obj_obj_metres": "Wavefront OBJ (.obj; metros)…", "app.width_height": "Largura, altura", + "app.push_pull_total": "Total: {total}", + "app.width": "Largura", + "app.height": "Altura", "app.wood_joinery": "Encaixes de madeira", "app.your_model_stays_on_this_device": "Seu modelo fica neste dispositivo", "app.your_work_is_saved_automatically_on_this_device": "Seu trabalho é salvo automaticamente neste dispositivo. Salve um projeto .bup para ter uma cópia portátil. Arquivos .bup antigos também são aceitos.", @@ -2182,5 +2185,6 @@ "app.calculating_measurements": "Calculando medições…", "sidebar.move_wireframe": "Exibir outros objetos em aramado ao mover", "sidebar.selected_edge_lengths": "Comprimentos das arestas selecionadas", + "sidebar.floating_dimension_input": "Caixa de medidas flutuante", "cli.agent_edit": "Edição do agente" } diff --git a/assets/i18n/vi_VN.json b/assets/i18n/vi_VN.json index 7e8bc79..05ab141 100644 --- a/assets/i18n/vi_VN.json +++ b/assets/i18n/vi_VN.json @@ -64,7 +64,7 @@ "app.click_a_filled_face_first": "Trước tiên, hãy nhấp vào một mặt đã tô.", "app.click_a_reference_point_away_from_the_pivot": "Nhấp vào điểm tham chiếu cách xa trục trước", "app.click_a_reference_point_away_from_the_pivot_2": "Nhấp vào điểm tham chiếu cách xa trục quay · Esc để hủy", - "app.click_the_opposite_corner_type_width_height_5m": "Nhấp vào góc đối diện · Nhập chiều rộng,chiều cao (5m,50mm) rồi nhấn Enter", + "app.click_the_opposite_corner_type_width_height_5m": "Nhấp vào góc đối diện · Nhập chiều rộng, Tab để nhập chiều cao (hoặc 5m,50mm) + Enter", "app.click_to_confirm_esc_to_cancel": "{} · Nhấp để xác nhận · Esc để hủy", "app.click_to_confirm_esc_to_cancel_2": "{value:.3} × · Nhấp để xác nhận · Esc để hủy", "app.click_to_confirm_esc_to_cancel_3": "{}{}{alignment} · Nhấp để xác nhận · Esc để hủy", @@ -261,6 +261,9 @@ "app.wavefront_obj_obj": "Wavefront OBJ (.obj)…", "app.wavefront_obj_obj_metres": "Wavefront OBJ (.obj; mét)…", "app.width_height": "Chiều rộng, chiều cao", + "app.push_pull_total": "Tổng: {total}", + "app.width": "Chiều rộng", + "app.height": "Chiều cao", "app.wood_joinery": "Mối nối gỗ", "app.your_model_stays_on_this_device": "Mô hình của bạn vẫn ở trên thiết bị này", "app.your_work_is_saved_automatically_on_this_device": "Công việc của bạn được lưu tự động trên thiết bị này. Lưu dự án .bup để giữ một bản sao di động. Các tệp .bup cũ cũng được hỗ trợ.", @@ -2182,5 +2185,6 @@ "app.calculating_measurements": "Tính số đo…", "sidebar.move_wireframe": "Hiển thị khung dây cho đối tượng khác khi di chuyển", "sidebar.selected_edge_lengths": "Chiều dài các cạnh đã chọn", + "sidebar.floating_dimension_input": "Ô nhập kích thước nổi", "cli.agent_edit": "Chỉnh sửa của tác nhân" } diff --git a/assets/i18n/zh_CN.json b/assets/i18n/zh_CN.json index 5d4a522..29e08eb 100644 --- a/assets/i18n/zh_CN.json +++ b/assets/i18n/zh_CN.json @@ -64,7 +64,7 @@ "app.click_a_filled_face_first": "请先单击已填充的面", "app.click_a_reference_point_away_from_the_pivot": "请先单击远离旋转中心的参考点", "app.click_a_reference_point_away_from_the_pivot_2": "单击远离旋转中心的参考点 · 按 Esc 取消", - "app.click_the_opposite_corner_type_width_height_5m": "单击对角点 · 输入 宽度,高度(5m,50mm)+ Enter", + "app.click_the_opposite_corner_type_width_height_5m": "点击对角 · 输入宽度,按 Tab 输入高度(或 5m,50mm)+ Enter", "app.click_to_confirm_esc_to_cancel": "{} · 单击确认 · 按 Esc 取消", "app.click_to_confirm_esc_to_cancel_2": "{value:.3} × · 单击确认 · 按 Esc 取消", "app.click_to_confirm_esc_to_cancel_3": "{}{}{alignment} · 单击确认 · 按 Esc 取消", @@ -261,6 +261,9 @@ "app.wavefront_obj_obj": "Wavefront OBJ (.obj)…", "app.wavefront_obj_obj_metres": "Wavefront OBJ (.obj;米)…", "app.width_height": "宽度、高度", + "app.push_pull_total": "总计:{total}", + "app.width": "宽度", + "app.height": "高度", "app.wood_joinery": "木工接合", "app.your_model_stays_on_this_device": "您的模型保存在本设备上", "app.your_work_is_saved_automatically_on_this_device": "您的工作会自动保存在本设备上。保存 .bup 项目可保留一份便携副本。也支持旧版 .bup 文件。", @@ -2182,5 +2185,6 @@ "app.calculating_measurements": "正在计算测量数据…", "sidebar.move_wireframe": "移动时以线框形式显示其他对象", "sidebar.selected_edge_lengths": "所选边长", + "sidebar.floating_dimension_input": "浮动尺寸输入框", "cli.agent_edit": "智能体编辑" } diff --git a/crates/benchup-app/src/app.rs b/crates/benchup-app/src/app.rs index ed9ae5f..e0e3fcd 100644 --- a/crates/benchup-app/src/app.rs +++ b/crates/benchup-app/src/app.rs @@ -51,6 +51,8 @@ mod currency; pub(crate) mod display_scene; #[path = "construction_guides.rs"] mod construction_guides; +#[path = "dimension_overlay.rs"] +mod dimension_overlay; #[path = "drawing_inference.rs"] mod drawing_inference; #[path = "entity_gizmo.rs"] @@ -403,6 +405,10 @@ pub struct BenchUpApp { lock: Option, second_lock: Option, length_input: String, + /// Rectangle typing goes to height instead of width (toggled with Tab). + rect_height_active: bool, + /// After Tab, the active rectangle field is selected: typing replaces it. + rect_field_selected: bool, modeling: Option, push_pull_new_faces: bool, push_pull_modifier_down: bool, @@ -596,6 +602,8 @@ impl BenchUpApp { lock: None, second_lock: None, length_input: String::new(), + rect_height_active: false, + rect_field_selected: false, modeling: None, push_pull_new_faces: false, push_pull_modifier_down: false, @@ -1505,10 +1513,20 @@ impl BenchUpApp { .fold(0., f64::max) } + /// Resulting depth when pushing an already extruded face. `None` for a + /// fresh face, where it always equals the distance and would repeat it. + fn push_pull_total(&self, edit: &Modeling, value: f64) -> Option { + let total = (edit.pull_depth + value * edit.direction.dot(edit.unlocked_direction)).abs(); + (edit.pull_depth.abs() > 1e-9).then_some(total) + } + fn push_pull_readout(&self, edit: &Modeling, value: f64) -> String { let units = self.doc.display.units; - let total = (edit.pull_depth + value * edit.direction.dot(edit.unlocked_direction)).abs(); - format!("Difference: {} · Total: {}", units.format_length(value), units.format_length(total)) + let distance = units.format_length(value); + match self.push_pull_total(edit, value) { + Some(total) => format!("{distance} · {}", tr!("app.push_pull_total", total = units.format_length(total))), + None => distance, + } } fn selected_modeling_face(&self) -> Option { @@ -2345,6 +2363,29 @@ impl BenchUpApp { fn sidebar_shortcut(&mut self, ctx: &egui::Context) { let walking = self.tool == Tool::Walk; + // While sizing a rectangle, Tab switches between its width and height. + if self.rect_fields_active() { + let tab = ctx.input_mut(|i| { + let mut tab = false; + i.events.retain(|event| { + if let egui::Event::Key { key: Key::Tab, pressed: true, modifiers, .. } = event + && (!modifiers.any() || *modifiers == egui::Modifiers::SHIFT) + { + tab = true; + false + } else { true } + }); + tab + }); + if tab { + self.toggle_rect_field(); + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + ctx.request_repaint(); + } + return; + } + self.rect_height_active = false; + self.rect_field_selected = false; let consumed = ctx.input_mut(|i| { let mut consumed = false; if i.focused { @@ -2631,7 +2672,7 @@ impl BenchUpApp { // unit suffixes. Letter shortcuts resume after the dimension is cleared. if self.precision_available() && !self.length_input.is_empty() - && (Key::A..=Key::Z).contains(&key) + && ((Key::A..=Key::Z).contains(&key) || key == Key::Space) { continue; } @@ -2642,7 +2683,9 @@ impl BenchUpApp { match key { Key::Delete => self.erase_selected(ctx), Key::Backspace => { - if self.precision_available() { + if self.rect_fields_active() { + self.rect_input_backspace(); + } else if self.precision_available() { self.length_input.pop(); } else { self.erase_selected(ctx); @@ -2692,7 +2735,11 @@ impl BenchUpApp { ) }) && self.length_input.len() + text.len() <= precision::MAX_INPUT_LEN { - self.length_input.push_str(&text); + if self.rect_fields_active() { + self.rect_input_text(&text); + } else { + self.length_input.push_str(&text); + } } } _ => {} @@ -3286,6 +3333,9 @@ impl BenchUpApp { // Reserve the precision controls before fitting the status message. ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if self.tool == Tool::Text { return; } + // The floating boxes on the geometry replace this field; + // it returns when they are turned off in View options. + if self.doc.display.dimension_input_overlay && !self.tool.is_measuring() { return; } if self.tool.is_measuring() && !self.measurement_precision_available() { ui.text_label( RichText::new( @@ -3360,19 +3410,7 @@ impl BenchUpApp { field.surrender_focus(); } ui.text_label( - RichText::new(match self.tool { - Tool::Scale => tr!("app.factor"), - Tool::Rotate | Tool::Protractor => tr!("app.angle"), - Tool::PushPull => tr!("app.distance"), - Tool::Offset => tr!("app.offset"), - Tool::Move if self.last_move_copy.is_some() => tr!("app.copies"), - Tool::Move => tr!("app.distance"), - Tool::Rectangle | Tool::Image => tr!("app.width_height"), - Tool::Circle | Tool::Polygon => tr!("app.radius"), - Tool::Arc if self.drawing.as_ref().is_some_and(|drawing| drawing.arc_end.is_some()) => tr!("app.arc_height"), - Tool::Arc => tr!("app.arc_chord"), - _ => tr!("app.length"), - }) + RichText::new(self.precision_field_label()) .size(12.) .color(colors.muted), ); @@ -6459,7 +6497,8 @@ impl BenchUpApp { colors.paper.gamma_multiply(0.95), ); p.galley(at, galley, color); - if let Some(start) = self.line_start { + // The floating dimension box replaces this inline length. + if let Some(start) = self.line_start.filter(|_| !display.dimension_input_overlay) { if let Some(a) = screen(start) { // Keep the length clear of the endpoint's inference // tooltip, including short vertical previews. @@ -6618,6 +6657,7 @@ impl BenchUpApp { self.paint_construction_guides(&p, rect); self.paint_measurements(&p, rect); self.paint_scale_cage(&p, rect); + self.paint_dimension_overlay(&p, rect); if let Some((notice, until)) = &self.notice { if ui.input(|i| i.time) < *until { let galley = p.layout( diff --git a/crates/benchup-app/src/app_tests.rs b/crates/benchup-app/src/app_tests.rs index 77eca1f..13db442 100644 --- a/crates/benchup-app/src/app_tests.rs +++ b/crates/benchup-app/src/app_tests.rs @@ -4869,6 +4869,7 @@ fn move_copy_arrays_inside_component_stay_in_context_and_update_peers() { #[test] fn move_copy_array_can_be_entered_in_the_measurements_field() { let mut h = Harness::new(); + h.app.doc.display.dimension_input_overlay = false; begin_move_copy(&mut h, false); modeling_number(&mut h, "6"); h.click(egui::pos2(h.size.x - 90., h.size.y - 21.)); @@ -4881,6 +4882,19 @@ fn move_copy_array_can_be_entered_in_the_measurements_field() { assert!(h.app.length_input.is_empty()); } +#[test] +fn floating_dimension_boxes_replace_the_measurements_field() { + let mut h = Harness::new(); + assert!(h.app.doc.display.dimension_input_overlay); + begin_move_copy(&mut h, false); + modeling_number(&mut h, "6"); + h.click(egui::pos2(h.size.x - 90., h.size.y - 21.)); + assert!(!h.ctx.egui_wants_keyboard_input(), "the field is hidden"); + // Typing still reaches the dimension without the field. + modeling_number(&mut h, "/3"); + assert_copy_positions(&h, &[0., 2., 4., 6.]); +} + fn ui_solid_box(model: &mut Model, min: DVec3, max: DVec3) -> u64 { let mut body = Model::default(); let corners = [ diff --git a/crates/benchup-app/src/arc_tool_tests.rs b/crates/benchup-app/src/arc_tool_tests.rs index 8c847b6..000bacc 100644 --- a/crates/benchup-app/src/arc_tool_tests.rs +++ b/crates/benchup-app/src/arc_tool_tests.rs @@ -98,3 +98,38 @@ fn collinear_arc_stays_pending_and_escape_discards_it() { assert!(h.app.drawing.is_none()); assert!(h.app.doc.model.edges.is_empty()); } + +#[test] +fn arc_height_box_measures_the_sagitta_on_the_pointer_side() { + let mut h = Harness::new(); + h.app.doc.snap_to_geometry = false; + h.app.set_tool(Tool::Arc); + h.click(h.at(DVec3::ZERO)); + h.click(h.at(DVec3::new(2., 0., 0.))); + let drawing = h.app.drawing.as_ref().unwrap(); + let (start, end) = (drawing.origin, drawing.arc_end.unwrap()); + let midpoint = (start + end) * 0.5; + let across = drawing.normal.cross((end - start).normalize()); + let mut snap = h.app.snap.unwrap(); + // A pointer at the apex gives its own distance from the chord. + snap.point = midpoint + across * 0.5; + h.app.snap = Some(snap); + let (mid, apex, height) = h.app.arc_height_segment().unwrap(); + assert!(mid.distance(midpoint) < 1e-9); + assert!((height - 0.5).abs() < 1e-9, "{height}"); + assert!(apex.distance(snap.point) < 1e-9); + // Off-center pointers report the apex of the arc through them. + snap.point = start + (end - start) * 0.25 + across * 0.3; + h.app.snap = Some(snap); + let (_, _, height) = h.app.arc_height_segment().unwrap(); + let sagitta = h.app.arc_points(false).unwrap().iter() + .map(|p| (*p - start).dot(across)) + .fold(0., f64::max); + assert!((height - sagitta).abs() < 1e-2, "{height} vs {sagitta}"); + assert!(height > 0.3); + // A typed height is shown as-is, on the pointer's side. + h.app.length_input = "10cm".into(); + let (_, apex, height) = h.app.arc_height_segment().unwrap(); + assert!((height - 0.1).abs() < 1e-9); + assert!((apex - midpoint).dot(across) > 0.); +} diff --git a/crates/benchup-app/src/dimension_overlay.rs b/crates/benchup-app/src/dimension_overlay.rs new file mode 100644 index 0000000..b830d27 --- /dev/null +++ b/crates/benchup-app/src/dimension_overlay.rs @@ -0,0 +1,393 @@ +//! Floating dimension inputs drawn on the geometry being sized. +//! +//! Typed characters already flow into `length_input` without focusing the +//! status-bar field. These boxes mirror that buffer (or the live value when +//! nothing is typed) centered on the edge that the value changes, so the user +//! sees where the dimension lands. Rectangles show one box per side; Tab +//! switches which side receives typing. The boxes are display-only: a second +//! focusable text field would compete with the global keyboard routing. +//! +//! Rectangle input keeps the `width,height` buffer format, so the status-bar +//! field, formulas, and commit logic are unchanged. Either side may be left +//! empty to keep the size under the cursor. + +use benchup_core::tr; +use super::*; + +/// Where a floating box is attached in screen space. +enum OverlayAnchor { + /// Centered on a projected segment's midpoint. + Segment(Pos2, Pos2), + /// Beside a single point, such as the pointer. + Point(Pos2), +} + +struct DimensionBox { + anchor: OverlayAnchor, + label: &'static str, + text: String, + /// Receives typed characters. + active: bool, + /// Holds a typed (locked) value rather than the live cursor value. + typed: bool, + /// The whole value is selected; typing replaces it. + selected: bool, + /// Secondary line under the value, such as Push/Pull's resulting depth. + note: Option, + invalid: bool, +} + +/// Gap between a point anchor and the box, in points. +const POINT_GAP: f32 = 24.; + +impl BenchUpApp { + /// Label shared by the status-bar field and the floating box. + pub(super) fn precision_field_label(&self) -> &'static str { + match self.tool { + Tool::Scale => tr!("app.factor"), + Tool::Rotate | Tool::Protractor => tr!("app.angle"), + Tool::PushPull => tr!("app.distance"), + Tool::Offset => tr!("app.offset"), + Tool::Move if self.last_move_copy.is_some() => tr!("app.copies"), + Tool::Move => tr!("app.distance"), + Tool::Rectangle | Tool::Image => tr!("app.width_height"), + Tool::Circle | Tool::Polygon => tr!("app.radius"), + Tool::Arc + if self + .drawing + .as_ref() + .is_some_and(|drawing| drawing.arc_end.is_some()) => + { + tr!("app.arc_height") + } + Tool::Arc => tr!("app.arc_chord"), + _ => tr!("app.length"), + } + } + + /// A rectangle is being drawn, so input is split into width and height. + pub(super) fn rect_fields_active(&self) -> bool { + self.drawing.is_some() && matches!(self.tool, Tool::Rectangle | Tool::Image) + } + + fn rect_parts(&self) -> (String, String) { + match self.length_input.split_once(',') { + Some((width, height)) => (width.to_owned(), height.to_owned()), + None => (self.length_input.clone(), String::new()), + } + } + + fn set_rect_parts(&mut self, width: String, height: String) { + self.length_input = if height.is_empty() && !self.rect_height_active { + width + } else { + format!("{width},{height}") + }; + } + + /// Insert typed text into the active rectangle side. A comma moves to height. + /// After Tab the active side is selected, so the first character replaces it. + pub(super) fn rect_input_text(&mut self, text: &str) { + let (mut width, mut height) = self.rect_parts(); + let (mut height_active, mut selected) = (self.rect_height_active, self.rect_field_selected); + for c in text.chars() { + if c == ',' { + height_active = true; + selected = true; + continue; + } + let field = if height_active { &mut height } else { &mut width }; + if std::mem::take(&mut selected) { + field.clear(); + } + field.push(c); + } + // Count the separator, matching the plain `length_input` limit. + if width.len() + 1 + height.len() <= precision::MAX_INPUT_LEN { + self.rect_height_active = height_active; + self.rect_field_selected = selected; + self.set_rect_parts(width, height); + } + } + + /// Delete from the active rectangle side. + pub(super) fn rect_input_backspace(&mut self) { + let (mut width, mut height) = self.rect_parts(); + let field = if self.rect_height_active { &mut height } else { &mut width }; + if std::mem::take(&mut self.rect_field_selected) { + field.clear(); + } else { + field.pop(); + } + self.set_rect_parts(width, height); + } + + /// Tab: switch typing between the rectangle's width and height. + pub(super) fn toggle_rect_field(&mut self) { + let (width, height) = self.rect_parts(); + self.rect_height_active = !self.rect_height_active; + self.rect_field_selected = true; + self.set_rect_parts(width, height); + } + + fn rect_boxes(&self, screen: &impl Fn(DVec3) -> Option) -> Option> { + // Same points as the drawn preview, including the Image tool's offset. + let points = self.drawing_points(); + let [corner, width_end, far, ..] = points.as_slice() else { + return None; + }; + let units = self.doc.display.units; + let (width, height) = self.rect_parts(); + let field = |typed: &str, active: bool, label, a: DVec3, b: DVec3| -> Option { + // Trim only to detect an empty side; show the text as typed so a + // trailing space is visible while entering "2 cm". + let empty = typed.trim().is_empty(); + Some(DimensionBox { + anchor: OverlayAnchor::Segment(screen(a)?, screen(b)?), + label, + text: if empty { + units.format_length(a.distance(b)) + } else { + typed.to_owned() + }, + active, + typed: !empty, + selected: active && self.rect_field_selected && !empty, + note: None, + invalid: !empty && precision::positive_length(typed, units.length).is_err(), + }) + }; + Some(vec![ + field(&width, !self.rect_height_active, tr!("app.width"), *corner, *width_end)?, + field(&height, self.rect_height_active, tr!("app.height"), *width_end, *far)?, + ]) + } + + /// Anchor and live value for the operation being sized. + fn dimension_target(&self, screen: &impl Fn(DVec3) -> Option) -> Option<(OverlayAnchor, String)> { + let units = self.doc.display.units; + if let Some(edit) = &self.modeling { + let value = precision::modeling_value_with_units(self.tool, &self.length_input, units) + .unwrap_or(edit.value); + let text = match self.tool { + Tool::Scale => format!("{value:.3}"), + Tool::Rotate => units.format_angle(value), + _ => units.format_length(value), + }; + let anchor = match self.tool { + Tool::Move => match (screen(edit.anchor), screen(edit.anchor + edit.direction)) { + (Some(a), Some(b)) if a.distance(b) > 1. => OverlayAnchor::Segment(a, b), + _ => OverlayAnchor::Point(edit.pointer), + }, + _ => OverlayAnchor::Point(edit.pointer), + }; + return Some((anchor, text)); + } + if self.last_move_copy.is_some() { + let point = self.viewport_pointer?; + return Some((OverlayAnchor::Point(point), "/3 · 3x".into())); + } + let snap = self.snap?; + if self.tool == Tool::Line { + let start = self.line_start?; + let anchor = OverlayAnchor::Segment(screen(start)?, screen(snap.point)?); + return Some((anchor, units.format_length(start.distance(snap.point)))); + } + let drawing = self.drawing.as_ref()?; + let origin = drawing.origin; + let delta = snap.point - origin; + match self.tool { + Tool::Freehand => { + let points = self.raw_drawing_points(); + let total: f64 = points.windows(2).map(|p| p[0].distance(p[1])).sum(); + Some((OverlayAnchor::Point(screen(snap.point)?), units.format_length(total))) + } + Tool::Circle | Tool::Polygon => Some(( + OverlayAnchor::Segment(screen(origin)?, screen(snap.point)?), + units.format_length(delta.length()), + )), + Tool::Arc if drawing.arc_end.is_none() => Some(( + OverlayAnchor::Segment(screen(origin)?, screen(snap.point)?), + units.format_length(delta.length()), + )), + Tool::Arc => { + let (midpoint, apex, height) = self.arc_height_segment()?; + Some(( + OverlayAnchor::Segment(screen(midpoint)?, screen(apex)?), + units.format_length(height), + )) + } + _ => None, + } + } + + /// The arc's height line: chord midpoint, apex, and height (sagitta). + /// + /// Matches `arc_points`: a typed height places the apex on the + /// perpendicular through the chord midpoint, on the pointer's side; + /// otherwise the arc passes through the pointer, and its apex is the + /// circle point on that side above the chord midpoint. + pub(super) fn arc_height_segment(&self) -> Option<(DVec3, DVec3, f64)> { + let drawing = self.drawing.as_ref()?; + let (start, end, pointer) = (drawing.origin, drawing.arc_end?, self.snap?.point); + let chord = end - start; + let width = chord.length(); + if width < 1e-9 { + return None; + } + let u = chord / width; + let midpoint = start + chord * 0.5; + let offset = pointer - start; + let x = offset.dot(u); + let across = offset - u * x; + let y = across.length(); + let side = across + .try_normalize() + .or_else(|| drawing.normal.cross(u).try_normalize())?; + let typed = precision::positive_length(&self.length_input, self.doc.display.units.length).ok(); + let height = typed.unwrap_or_else(|| { + if y < 1e-9 { + return 0.; + } + // Circle center in the chord frame is (width / 2, cy). + let cy = (x * (x - width) + y * y) / (2. * y); + cy + (width * width / 4. + cy * cy).sqrt() + }); + height.is_finite().then(|| (midpoint, midpoint + side * height, height)) + } + + /// Push/Pull on an already extruded face: its resulting depth (see `push_pull_total`). + pub(super) fn dimension_note(&self) -> Option { + let edit = self.modeling.as_ref().filter(|_| self.tool == Tool::PushPull)?; + let units = self.doc.display.units; + let value = precision::modeling_value_with_units(self.tool, &self.length_input, units) + .unwrap_or(edit.value); + let total = self.push_pull_total(edit, value)?; + Some(tr!("app.push_pull_total", total = units.format_length(total))) + } + + /// Whether the typed text cannot be applied to the current operation. + fn dimension_input_invalid(&self) -> bool { + if self.length_input.trim().is_empty() || self.last_move_copy.is_some() { + return false; + } + let units = self.doc.display.units; + if self.modeling.is_some() { + precision::modeling_value_with_units(self.tool, &self.length_input, units).is_err() + } else if self.drawing.is_some() { + self.precise_drawing_points().is_err() + } else { + precision::positive_length(&self.length_input, units.length).is_err() + } + } + + pub(super) fn paint_dimension_overlay(&self, p: &egui::Painter, rect: Rect) { + if !self.doc.display.dimension_input_overlay + || self.history_preview.is_some() + || self.tool.is_measuring() + || !self.precision_available() + { + return; + } + let size = dv(rect.size()); + let screen = |point: DVec3| self.doc.camera.project(point, size).map(|v| rect.min + pv(v)); + let boxes = if self.rect_fields_active() { + self.rect_boxes(&screen) + } else { + self.dimension_target(&screen).map(|(anchor, live)| { + let typed = !self.length_input.is_empty(); + vec![DimensionBox { + anchor, + label: self.precision_field_label(), + text: if typed { self.length_input.clone() } else { live }, + active: true, + typed, + selected: false, + note: self.dimension_note(), + invalid: self.dimension_input_invalid(), + }] + }) + }; + // Draw the active box last so it stays on top where boxes overlap. + let mut boxes = boxes.unwrap_or_default(); + boxes.sort_by_key(|b| b.active); + for dimension in boxes { + self.paint_dimension_box(p, rect, dimension); + } + } + + fn paint_dimension_box(&self, p: &egui::Painter, rect: Rect, dimension: DimensionBox) { + let colors = Palette::for_painter(p); + let editing = dimension.active && dimension.typed; + let label = p.layout_no_wrap(dimension.label.to_owned(), FontId::proportional(10.), colors.muted); + let value = p.layout_no_wrap( + if dimension.text.is_empty() { "—".to_owned() } else { dimension.text }, + FontId::monospace(14.), + if dimension.typed || dimension.active { colors.ink } else { colors.muted }, + ); + let padding = egui::vec2(9., 5.); + let note = dimension + .note + .map(|note| p.layout_no_wrap(note, FontId::proportional(10.), colors.muted)); + let note_size = note.as_ref().map_or(Vec2::ZERO, |note| note.size() + egui::vec2(0., 1.)); + let caret = if dimension.active { 5. } else { 0. }; + let inner = egui::vec2( + label.size().x.max(value.size().x + caret).max(note_size.x), + label.size().y + 1. + value.size().y + note_size.y, + ); + let box_size = inner + padding * 2.; + let center = match dimension.anchor { + // Sit on the edge itself, where the value takes effect. + OverlayAnchor::Segment(a, b) => a.lerp(b, 0.5), + OverlayAnchor::Point(point) => { + point + egui::vec2(POINT_GAP, -POINT_GAP) + box_size * egui::vec2(0.5, -0.5) + } + }; + let bounds = rect.shrink(8.); + if bounds.width() < box_size.x || bounds.height() < box_size.y { + return; + } + let min = (center - box_size * 0.5).clamp(bounds.min, bounds.max - box_size); + let frame = Rect::from_min_size(min, box_size); + // Use the theme's text color (white in dark mode) rather than the + // inference green, so the active field stands apart from snapping cues. + let border = if dimension.invalid { + ACCENT + } else if dimension.active { + colors.ink + } else { + colors.border + }; + if dimension.active { + p.rect_filled(frame.translate(egui::vec2(0., 2.)), 6, Color32::from_black_alpha(40)); + } + // Inactive fields recede so the one receiving typing is obvious. + p.rect_filled(frame, 6, if dimension.active { colors.paper } else { colors.paper.gamma_multiply(0.75) }); + p.rect_stroke( + frame, + 6, + Stroke::new(if dimension.active { 2. } else { 1. }, border), + egui::StrokeKind::Inside, + ); + let label_at = frame.min + padding; + let value_at = label_at + egui::vec2(0., label.size().y + 1.); + let value_size = value.size(); + p.galley(label_at, label, colors.muted); + if dimension.selected { + let selection = p.ctx().global_style().visuals.selection.bg_fill; + p.rect_filled(Rect::from_min_size(value_at, value_size).expand2(egui::vec2(2., 1.)), 2, selection); + } + p.galley(value_at, value, colors.ink); + if let Some(note) = note { + p.galley(value_at + egui::vec2(0., value_size.y + 1.), note, colors.muted); + } + if dimension.active && !dimension.selected { + // A typed value gets a caret at its end; a live value shows where typing starts. + let x = if editing { value_at.x + value_size.x + 2. } else { value_at.x - 2. }; + p.line_segment( + [egui::pos2(x, value_at.y + 1.), egui::pos2(x, value_at.y + value_size.y - 1.)], + Stroke::new(1.5, border), + ); + } + } +} diff --git a/crates/benchup-app/src/precision.rs b/crates/benchup-app/src/precision.rs index 8caf995..4fd98bd 100644 --- a/crates/benchup-app/src/precision.rs +++ b/crates/benchup-app/src/precision.rs @@ -321,12 +321,23 @@ impl BenchUpApp { let delta = self.snap.map_or(DVec3::ZERO, |snap| snap.point - origin); match self.tool { Tool::Rectangle | Tool::Image => { + // Either side may be left empty (Tab-separated floating inputs) + // to keep the size under the cursor. let (width, height) = self .length_input .split_once(',') - .ok_or(tr!("precision.enter_width_height_such_as_5m_50mm"))?; - let width = positive_length(width, self.doc.display.units.length)?; - let height = positive_length(height, self.doc.display.units.length)?; + .unwrap_or((self.length_input.as_str(), "")); + let side = |typed: &str, live: f64| { + if typed.trim().is_empty() { + (live > 1e-7) + .then_some(live) + .ok_or(tr!("precision.enter_width_height_such_as_5m_50mm")) + } else { + positive_length(typed, self.doc.display.units.length) + } + }; + let width = side(width, delta.dot(drawing.u).abs())?; + let height = side(height, delta.dot(drawing.v).abs())?; let direction = |value: f64| if value < 0. { -1. } else { 1. }; let a = drawing.u * width * direction(delta.dot(drawing.u)); let b = drawing.v * height * direction(delta.dot(drawing.v)); diff --git a/crates/benchup-app/src/precision_tests.rs b/crates/benchup-app/src/precision_tests.rs index 803f9dd..101dc83 100644 --- a/crates/benchup-app/src/precision_tests.rs +++ b/crates/benchup-app/src/precision_tests.rs @@ -32,9 +32,9 @@ fn type_dimension(h: &mut Harness, input: &str) { } #[test] -fn space_stops_every_drawing_tool_with_or_without_a_typed_dimension() { +fn space_stops_every_drawing_tool_before_a_dimension_is_typed() { for tool in Tool::DRAWING { - for input in ["", "2cm"] { + for input in [""] { let mut h = Harness::new(); h.app.set_tool(tool); let start = h.at(DVec3::ZERO); @@ -66,6 +66,36 @@ fn space_stops_every_drawing_tool_with_or_without_a_typed_dimension() { } } +#[test] +fn space_inside_a_typed_dimension_separates_number_and_unit() { + for tool in Tool::DRAWING { + let mut h = Harness::new(); + h.app.set_tool(tool); + let start = h.at(DVec3::ZERO); + let end = h.at(DVec3::new(2., 1., 0.)); + if tool == Tool::Freehand { + h.pointer(start, egui::PointerButton::Primary, true, false); + } else { + h.click(start); + } + h.frame(vec![egui::Event::PointerMoved(end)], egui::Modifiers::NONE); + // Space is the Select shortcut and C the Circle shortcut; neither may + // fire while a dimension is being typed. + type_dimension(&mut h, "2 cm"); + assert_eq!(h.app.length_input, "2 cm", "{tool:?}"); + assert_eq!(h.app.tool, tool, "{tool:?}"); + assert!(h.app.line_start.is_some() || h.app.drawing.is_some(), "{tool:?}"); + // Esc still cancels a dimension that contains spaces. + h.key(Key::Escape); + assert!(h.app.length_input.is_empty(), "{tool:?}"); + } + let units = benchup_core::UnitSettings::default().length; + assert_eq!( + precision::positive_length("2 cm", units), + precision::positive_length("2cm", units), + ); +} + #[test] fn space_ends_a_line_chain_and_keeps_completed_segments() { let mut h = Harness::new(); @@ -271,6 +301,8 @@ fn circles_and_polygons_accept_a_radius_with_units() { #[test] fn rectangle_dimensions_can_be_entered_in_the_focused_measurements_field() { let mut h = Harness::new(); + // The status-bar field is shown when floating dimension boxes are off. + h.app.doc.display.dimension_input_overlay = false; h.key(Key::R); h.click(h.at(DVec3::ZERO)); let output = h.frame(vec![], egui::Modifiers::NONE); @@ -416,3 +448,127 @@ fn modeling_distances_use_the_same_units_in_previews_and_commits() { h.app.doc.model.validate().unwrap(); } } + +#[test] +fn rectangle_tab_switches_between_width_and_height_fields() { + let mut h = Harness::new(); + h.app.set_tool(Tool::Rectangle); + h.click(h.at(DVec3::ZERO)); + type_dimension(&mut h, "50cm"); + assert_eq!(h.app.length_input, "50cm"); + h.key(Key::Tab); + assert!(h.app.rect_height_active); + type_dimension(&mut h, "20cm"); + assert_eq!(h.app.length_input, "50cm,20cm"); + // Tab returns to width and selects it: typing replaces the whole value. + h.key(Key::Tab); + assert!(!h.app.rect_height_active); + type_dimension(&mut h, "100cm"); + assert_eq!(h.app.length_input, "100cm,20cm"); + // Once replaced, Backspace edits only the active side, one character at a time. + h.key(Key::Backspace); + assert_eq!(h.app.length_input, "100c,20cm"); + type_dimension(&mut h, "m"); + assert_eq!(h.app.length_input, "100cm,20cm"); + // Backspace right after Tab clears the selected side. + h.key(Key::Tab); + h.key(Key::Backspace); + assert_eq!(h.app.length_input, "100cm,"); + type_dimension(&mut h, "20cm"); + assert_eq!(h.app.length_input, "100cm,20cm"); + // A typed comma still moves to height for the classic syntax. + h.key(Key::Tab); + h.key(Key::Tab); + type_dimension(&mut h, ","); + assert!(h.app.rect_height_active); + h.key(Key::Enter); + assert!(!h.app.doc.model.edges.is_empty()); + // Tab is not captured once the rectangle is finished. + h.frame(vec![], egui::Modifiers::NONE); + assert!(!h.app.rect_height_active); +} + +#[test] +fn rectangle_keeps_the_cursor_size_for_an_untyped_side() { + let mut h = Harness::new(); + h.app.doc.snap_to_geometry = false; + h.app.set_tool(Tool::Rectangle); + h.click(h.at(DVec3::ZERO)); + let corner = h.at(DVec3::new(0.3, 0.4, 0.)); + h.frame(vec![egui::Event::PointerMoved(corner)], egui::Modifiers::NONE); + type_dimension(&mut h, "50cm"); + let points = h.app.precise_drawing_points().unwrap(); + let width = points[0].distance(points[1]); + let height = points[1].distance(points[2]); + assert!((width - 0.5).abs() < 1e-9, "{width}"); + assert!(height > 1e-3, "{height}"); + assert!((height - h.app.raw_drawing_points()[1].distance(h.app.raw_drawing_points()[2])).abs() < 1e-9); +} + +#[test] +fn tab_switches_rectangle_fields_only_while_sizing_and_otherwise_toggles_the_inspector() { + let mut h = Harness::new(); + let inspector = h.app.inspector; + h.key(Key::Tab); + assert_ne!(h.app.inspector, inspector); + assert!(!h.app.rect_height_active); + h.app.set_tool(Tool::Rectangle); + h.click(h.at(DVec3::ZERO)); + let inspector = h.app.inspector; + h.key(Key::Tab); + assert_eq!(h.app.inspector, inspector); + assert!(h.app.rect_height_active); + // Shift+Tab also switches back while sizing. + let shift_tab = |pressed| egui::Event::Key { + key: Key::Tab, + physical_key: None, + pressed, + repeat: false, + modifiers: egui::Modifiers::SHIFT, + }; + h.frame(vec![shift_tab(true)], egui::Modifiers::SHIFT); + h.frame(vec![shift_tab(false)], egui::Modifiers::SHIFT); + assert!(!h.app.rect_height_active); + assert_eq!(h.app.inspector, inspector); +} + +#[test] +fn rectangle_field_input_over_the_limit_leaves_the_fields_unchanged() { + let mut h = Harness::new(); + h.app.set_tool(Tool::Rectangle); + h.click(h.at(DVec3::ZERO)); + type_dimension(&mut h, "5m"); + h.key(Key::Tab); + assert_eq!(h.app.length_input, "5m,"); + h.app.rect_input_text(&"1".repeat(precision::MAX_INPUT_LEN)); + assert_eq!(h.app.length_input, "5m,"); + assert!(h.app.rect_height_active); + assert!(h.app.rect_field_selected); + // Bounded input still applies, including the separator in the count. + let fits = "1".repeat(precision::MAX_INPUT_LEN - "5m,".len()); + h.app.rect_input_text(&fits); + assert_eq!(h.app.length_input, format!("5m,{fits}")); + assert!(!h.app.rect_field_selected); +} + +#[test] +fn invalid_rectangle_side_is_reported_and_blocks_the_commit() { + let mut h = Harness::new(); + h.app.doc.snap_to_geometry = false; + h.app.set_tool(Tool::Rectangle); + h.click(h.at(DVec3::ZERO)); + let corner = h.at(DVec3::new(0.3, 0.4, 0.)); + h.frame(vec![egui::Event::PointerMoved(corner)], egui::Modifiers::NONE); + for input in ["5kg", "5kg,20cm", "20cm,5kg", "0m"] { + h.app.length_input = input.into(); + assert!(h.app.precise_drawing_points().is_err(), "{input}"); + h.key(Key::Enter); + assert!(h.app.doc.model.edges.is_empty(), "{input}"); + assert!(h.app.drawing.is_some()); + } + // Height typed alone keeps the cursor width. + h.app.length_input = ",20cm".into(); + let points = h.app.precise_drawing_points().unwrap(); + assert!((points[1].distance(points[2]) - 0.2).abs() < 1e-9); + assert!(points[0].distance(points[1]) > 1e-3); +} diff --git a/crates/benchup-app/src/push_pull_tool_tests.rs b/crates/benchup-app/src/push_pull_tool_tests.rs index 2330e7b..b6ca54a 100644 --- a/crates/benchup-app/src/push_pull_tool_tests.rs +++ b/crates/benchup-app/src/push_pull_tool_tests.rs @@ -289,9 +289,13 @@ fn pull_readout_includes_existing_depth_and_signed_difference() { for (difference, total) in [(1., 3.), (-0.5, 1.5), (0., 2.)] { assert_eq!( h.app.push_pull_readout(edit, difference), - format!("Difference: {} · Total: {}", units.format_length(difference), units.format_length(total)), + format!("{} · Total: {}", units.format_length(difference), units.format_length(total)), ); } + // A fresh face has no prior depth: the total would repeat the distance. + h.app.modeling.as_mut().unwrap().pull_depth = 0.; + let fresh = h.app.modeling.as_ref().unwrap(); + assert_eq!(h.app.push_pull_readout(fresh, 1.), units.format_length(1.)); assert!(!h.app.project.modified); } @@ -564,3 +568,13 @@ fn pull_hover_supports_selected_groups_and_does_not_pick_through_other_groups() assert_eq!(h.app.doc.model.edit_context, None); } } + +#[test] +fn floating_push_pull_box_adds_the_total_only_for_extruded_faces() { + let (mut h, _) = block(); + h.click(h.at(DVec3::new(2., 2., 2.))); + let note = h.app.dimension_note().expect("an extruded face shows its total depth"); + assert!(note.starts_with("Total: "), "{note}"); + h.app.modeling.as_mut().unwrap().pull_depth = 0.; + assert_eq!(h.app.dimension_note(), None); +} diff --git a/crates/benchup-app/src/sidebar.rs b/crates/benchup-app/src/sidebar.rs index 4d52174..6699bdd 100644 --- a/crates/benchup-app/src/sidebar.rs +++ b/crates/benchup-app/src/sidebar.rs @@ -2213,6 +2213,27 @@ mod preference_tests { } } + #[test] + fn dimension_input_overlay_restores_without_model_edits_and_defaults_on() { + let old: DisplaySettings = serde_json::from_str("{}").unwrap(); + assert!(old.dimension_input_overlay); + let ctx = egui::Context::default(); + let mut storage = SavedPreferences::default(); + let mut app = BenchUpApp::with_document(Document::default(), None, true); + for enabled in [false, true] { + app.doc.display.dimension_input_overlay = enabled; + storage.flushed = false; + app.persist_inspector_changes(&ctx, Some(&mut storage), false); + assert!(storage.flushed); + assert!(!app.project.modified); + let mut restored = BenchUpApp::with_document(Document::default(), None, true); + restored.restore_inspector_preferences(&ctx, Some(&storage)); + assert_eq!(restored.doc.display.dimension_input_overlay, enabled); + restored.new_model(&ctx); + assert_eq!(restored.doc.display.dimension_input_overlay, enabled); + } + } + #[test] fn grain_direction_visibility_and_tool_restore_without_model_edits() { let old: DisplaySettings = serde_json::from_str("{}").unwrap(); @@ -5520,6 +5541,7 @@ impl BenchUpApp { (&mut d.group_names, tr!("sidebar.group_names")), (&mut d.dimensions, tr!("sidebar.dimensions")), (&mut d.face_side_lengths, tr!("sidebar.selected_edge_lengths")), + (&mut d.dimension_input_overlay, tr!("sidebar.floating_dimension_input")), (&mut d.grain_direction, tr!("grain_direction.show")), (&mut d.wood_joinery, tr!("sidebar.wood_joinery")), ] { @@ -5538,6 +5560,7 @@ impl BenchUpApp { // These application preferences do not modify the project. before.face_side_lengths = d.face_side_lengths; + before.dimension_input_overlay = d.dimension_input_overlay; before.grain_direction = d.grain_direction; before.group_emphasis = d.group_emphasis; if before != self.doc.display { diff --git a/crates/benchup-core/src/display.rs b/crates/benchup-core/src/display.rs index d34ca24..42a225b 100644 --- a/crates/benchup-core/src/display.rs +++ b/crates/benchup-core/src/display.rs @@ -57,6 +57,8 @@ pub struct DisplaySettings { pub dimensions: bool, /// Show selected edge lengths in the viewport (legacy serialized key). pub face_side_lengths: bool, + /// Show the dimension being typed in a floating box next to the active geometry. + pub dimension_input_overlay: bool, pub hidden_geometry: bool, pub hidden_objects: bool, pub group_emphasis: bool, @@ -118,6 +120,7 @@ impl Default for DisplaySettings { guides: true, dimensions: true, face_side_lengths: false, + dimension_input_overlay: true, hidden_geometry: false, hidden_objects: false, group_emphasis: true, From c15663aaa62a2309e4665a6fab6d1a5c552d5cb6 Mon Sep 17 00:00:00 2001 From: Danny Coulombe Date: Fri, 13 Nov 2026 18:09:19 -0500 Subject: [PATCH 2/2] Cancel Empty Precision Input With Space --- crates/benchup-app/src/app.rs | 26 ++++++- crates/benchup-app/src/dimension_overlay.rs | 9 ++- crates/benchup-app/src/precision.rs | 7 ++ crates/benchup-app/src/precision_tests.rs | 78 ++++++++++++++++++++- 4 files changed, 113 insertions(+), 7 deletions(-) diff --git a/crates/benchup-app/src/app.rs b/crates/benchup-app/src/app.rs index e0e3fcd..5301daa 100644 --- a/crates/benchup-app/src/app.rs +++ b/crates/benchup-app/src/app.rs @@ -2557,6 +2557,23 @@ impl BenchUpApp { }); self.prompt_drawn_group_name(ctx, completed_group); } + // An empty focused precision field still allows Space to cancel the tool. + if self.precision_available() + && !self.dimension_has_typed_value() + && ctx.memory(|m| m.has_focus(egui::Id::unique("precision_input"))) + && ctx.input(|i| { + i.modifiers.is_none() + && (i.key_pressed(Key::Space) + || i.events.iter().any(|event| matches!(event, + egui::Event::Text(text) if text == " "))) + && !i.events.iter().any(|event| matches!(event, + egui::Event::Text(text) if text.chars().any(|c| !c.is_whitespace()))) + }) + { + ctx.memory_mut(|m| m.surrender_focus(egui::Id::unique("precision_input"))); + self.set_tool(Tool::Select); + return; + } if ctx.egui_wants_keyboard_input() || self.help || self.object_creation.is_some() @@ -2671,7 +2688,7 @@ impl BenchUpApp { // Letter keys belong to an in-progress dimension, including // unit suffixes. Letter shortcuts resume after the dimension is cleared. if self.precision_available() - && !self.length_input.is_empty() + && self.dimension_has_typed_value() && ((Key::A..=Key::Z).contains(&key) || key == Key::Space) { continue; @@ -2727,6 +2744,12 @@ impl BenchUpApp { } } egui::Event::Text(text) if self.precision_available() => { + // Some backends deliver Space as text before its key event, + // or as text alone. Do not seed an empty dimension with it. + if text == " " && !self.dimension_has_typed_value() { + self.set_tool(Tool::Select); + continue; + } if text.chars().all(|c| { c.is_ascii_alphanumeric() || matches!( @@ -3383,6 +3406,7 @@ impl BenchUpApp { let field = ui.add_enabled( available, egui::TextEdit::singleline(&mut self.length_input) + .id(egui::Id::unique("precision_input")) .desired_width(if matches!(self.tool, Tool::Rectangle | Tool::Image) { 300. } else { 212. }) .font(egui::TextStyle::Monospace) .hint_text(hint) diff --git a/crates/benchup-app/src/dimension_overlay.rs b/crates/benchup-app/src/dimension_overlay.rs index b830d27..2b8947b 100644 --- a/crates/benchup-app/src/dimension_overlay.rs +++ b/crates/benchup-app/src/dimension_overlay.rs @@ -318,7 +318,7 @@ impl BenchUpApp { fn paint_dimension_box(&self, p: &egui::Painter, rect: Rect, dimension: DimensionBox) { let colors = Palette::for_painter(p); - let editing = dimension.active && dimension.typed; + let show_caret = dimension.active && dimension.typed && !dimension.selected; let label = p.layout_no_wrap(dimension.label.to_owned(), FontId::proportional(10.), colors.muted); let value = p.layout_no_wrap( if dimension.text.is_empty() { "—".to_owned() } else { dimension.text }, @@ -330,7 +330,7 @@ impl BenchUpApp { .note .map(|note| p.layout_no_wrap(note, FontId::proportional(10.), colors.muted)); let note_size = note.as_ref().map_or(Vec2::ZERO, |note| note.size() + egui::vec2(0., 1.)); - let caret = if dimension.active { 5. } else { 0. }; + let caret = if show_caret { 5. } else { 0. }; let inner = egui::vec2( label.size().x.max(value.size().x + caret).max(note_size.x), label.size().y + 1. + value.size().y + note_size.y, @@ -381,9 +381,8 @@ impl BenchUpApp { if let Some(note) = note { p.galley(value_at + egui::vec2(0., value_size.y + 1.), note, colors.muted); } - if dimension.active && !dimension.selected { - // A typed value gets a caret at its end; a live value shows where typing starts. - let x = if editing { value_at.x + value_size.x + 2. } else { value_at.x - 2. }; + if show_caret { + let x = value_at.x + value_size.x + 2.; p.line_segment( [egui::pos2(x, value_at.y + 1.), egui::pos2(x, value_at.y + value_size.y - 1.)], Stroke::new(1.5, border), diff --git a/crates/benchup-app/src/precision.rs b/crates/benchup-app/src/precision.rs index 4fd98bd..a59e3cb 100644 --- a/crates/benchup-app/src/precision.rs +++ b/crates/benchup-app/src/precision.rs @@ -306,6 +306,13 @@ impl BenchUpApp { || self.last_move_copy.is_some() } + pub(super) fn dimension_has_typed_value(&self) -> bool { + // Tab can insert the rectangle separator before either side is typed. + self.length_input.chars().any(|c| { + !c.is_whitespace() && !(self.rect_fields_active() && c == ',') + }) + } + pub(super) fn precise_drawing_points(&self) -> Result, &'static str> { if self.tool == Tool::Arc { return self.arc_points(true); diff --git a/crates/benchup-app/src/precision_tests.rs b/crates/benchup-app/src/precision_tests.rs index 101dc83..123b623 100644 --- a/crates/benchup-app/src/precision_tests.rs +++ b/crates/benchup-app/src/precision_tests.rs @@ -96,6 +96,83 @@ fn space_inside_a_typed_dimension_separates_number_and_unit() { ); } +#[test] +fn space_cancels_rectangle_with_only_an_empty_field_separator() { + for clear_typed_height in [false, true] { + let mut h = Harness::new(); + h.app.set_tool(Tool::Rectangle); + h.click(h.at(DVec3::ZERO)); + h.key(Key::Tab); + if clear_typed_height { + type_dimension(&mut h, "2"); + h.key(Key::Backspace); + } + assert_eq!(h.app.length_input, ","); + let before = h.app.doc.model.clone(); + + type_dimension(&mut h, " "); + + assert_eq!(h.app.tool, Tool::Select); + assert!(h.app.drawing.is_none()); + assert!(h.app.length_input.is_empty()); + assert_eq!(h.app.doc.model, before); + assert!(h.app.doc.history.undo_label().is_none()); + } +} + +#[test] +fn space_text_cancels_empty_input_regardless_of_key_event_order() { + for text_only in [false, true] { + let mut h = Harness::new(); + h.app.set_tool(Tool::Line); + h.click(h.at(DVec3::ZERO)); + let before = h.app.doc.model.clone(); + let mut events = vec![egui::Event::Text(" ".into())]; + if !text_only { + events.push(egui::Event::Key { + key: Key::Space, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }); + } + h.frame(events, egui::Modifiers::NONE); + + assert_eq!(h.app.tool, Tool::Select); + assert!(h.app.line_start.is_none()); + assert!(h.app.length_input.is_empty()); + assert_eq!(h.app.doc.model, before); + assert!(h.app.doc.history.undo_label().is_none()); + } +} + +#[test] +fn space_cancels_an_empty_focused_measurements_field() { + for text_only in [false, true] { + let mut h = Harness::new(); + h.app.doc.display.dimension_input_overlay = false; + h.app.set_tool(Tool::Rectangle); + h.click(h.at(DVec3::ZERO)); + let output = h.frame(vec![], egui::Modifiers::NONE); + h.click(text_position(&output, "5m,50mm")); + assert!(h.ctx.egui_wants_keyboard_input()); + let before = h.app.doc.model.clone(); + + if text_only { + h.frame(vec![egui::Event::Text(" ".into())], egui::Modifiers::NONE); + } else { + type_dimension(&mut h, " "); + } + + assert_eq!(h.app.tool, Tool::Select); + assert!(h.app.drawing.is_none()); + assert!(h.app.length_input.is_empty()); + assert_eq!(h.app.doc.model, before); + assert!(h.app.doc.history.undo_label().is_none()); + } +} + #[test] fn space_ends_a_line_chain_and_keeps_completed_segments() { let mut h = Harness::new(); @@ -105,7 +182,6 @@ fn space_ends_a_line_chain_and_keeps_completed_segments() { assert_eq!(h.app.doc.model.edges.len(), 1); assert!(h.app.line_start.is_some()); let before = h.app.doc.model.clone(); - type_dimension(&mut h, "5cm"); h.key(Key::Space); assert_eq!(h.app.tool, Tool::Select); assert!(h.app.line_start.is_none());