feat(measure): add radius and angle tools with dynamic measurement overlay

(cherry picked from commit aac44906a3)
This commit is contained in:
turbuczn authored and Hakan Seven committed 2026-10-15 00:22:41 +03:00
1 parent 843cbcd607
commit 3c55e6d7e1
42 files changed
+1624 -53

No files matched your search

+4
View File
@@ -1163,6 +1163,7 @@ impl OpenCADStudio {
fn handle_cancel(&mut self, cancel: CmdResult) {
let i = self.active_tab;
self.tabs[i].measurement.clear();
let space_changed = matches!(cancel, CmdResult::CancelForSpaceChange);
self.tabs[i].scene.clear_preview_wire();
if !space_changed && self.tabs[i].suspended_cmd.is_some() {
@@ -1304,6 +1305,7 @@ impl OpenCADStudio {
}
fn handle_measurement(&mut self, msg: String) {
self.remember_measurement(&msg);
let i = self.active_tab;
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
@@ -1315,6 +1317,7 @@ impl OpenCADStudio {
}
fn handle_report_measurement(&mut self, msg: String) {
self.remember_measurement(&msg);
let i = self.active_tab;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
@@ -1336,6 +1339,7 @@ impl OpenCADStudio {
}
fn handle_report_measurement_and_deselect(&mut self, msg: String) {
self.remember_measurement(&msg);
let i = self.active_tab;
self.tabs[i].snap_result = None;
self.tabs[i].scene.deselect_all();
+6 -2
View File
@@ -598,9 +598,13 @@ impl OpenCADStudio {
}
}
"MEASUREGEOM" => {
"MEASUREGEOM" | "MEASURERADIUS" | "MEASUREANGLE" => {
use crate::modules::draw::inquiry::measuregeom::MeasureGeomCommand;
let new_cmd = MeasureGeomCommand::new();
let new_cmd = match cmd {
"MEASURERADIUS" => MeasureGeomCommand::radius(),
"MEASUREANGLE" => MeasureGeomCommand::angle(),
_ => MeasureGeomCommand::new(),
};
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
+1
View File
@@ -64,6 +64,7 @@ impl OpenCADStudio {
/// (the block palette), so they don't inherit the previous command's last
/// point, snaps or dynamic-input values. (#1525)
pub(in crate::app) fn reset_command_start_state(&mut self, i: usize) {
self.tabs[i].measurement.clear();
// A command parked behind a transparent zoom / MTP goes with it.
self.tabs[i].suspended_cmd = None;
self.tabs[i].transparent_resume = false;
+11 -1
View File
@@ -308,7 +308,17 @@ fn command_category(name: &str) -> &'static str {
.contains(&name)
{
"modify"
} else if ["DIST", "ID", "AREA", "MEASURE", "MEASUREGEOM"].contains(&name) {
} else if [
"DIST",
"ID",
"AREA",
"MEASURE",
"MEASUREGEOM",
"MEASURERADIUS",
"MEASUREANGLE",
]
.contains(&name)
{
"inspect"
} else if ["ZOOM", "PAN", "UCS", "PLAN", "VPORTS", "3DORBIT"].contains(&name) {
"view"
+2
View File
@@ -157,6 +157,7 @@ pub(super) struct DocumentTab {
pub(super) properties: PropertiesPanel,
pub(super) layers: LayerPanel,
pub(super) active_cmd: Option<Box<dyn CadCommand>>,
pub(super) measurement: super::measurement::MeasurementDisplay,
/// Remaining command tokens queued behind a PAUSE (or `\`) awaiting user interaction.
pub(super) pending_pause_tokens: Option<Vec<String>>,
/// The selection set the most recent command worked on, captured when a
@@ -644,6 +645,7 @@ impl DocumentTab {
properties: PropertiesPanel::empty(),
layers: LayerPanel::default(),
active_cmd: None,
measurement: super::measurement::MeasurementDisplay::default(),
pending_pause_tokens: None,
last_cmd: None,
last_draw_anchor: None,
+288
View File
@@ -0,0 +1,288 @@
//! Per-drawing, transient inquiry UI; never serialized into document entities.
use super::OpenCADStudio;
use iced::Point;
#[derive(Clone, Debug)]
pub(super) struct MeasurementReadout {
pub text: String,
pub anchor: Point,
}
#[derive(Default)]
pub(super) struct MeasurementDisplay {
pub result: Option<MeasurementReadout>,
pub live_visible: bool,
}
impl MeasurementDisplay {
pub fn clear(&mut self) {
*self = Self::default();
}
/// Called on press before ordinary input processing. It cannot capture
/// the event, so this same press can also pick/select/pan/finish a command.
pub fn dismiss_result(&mut self) {
self.result = None;
}
}
impl OpenCADStudio {
pub(super) fn remember_measurement(&mut self, text: &str) {
let tab = &mut self.tabs[self.active_tab];
if tab
.active_cmd
.as_ref()
.is_some_and(|c| c.has_measurement_readout())
{
tab.measurement.result = Some(MeasurementReadout {
text: text.to_owned(),
anchor: tab.last_cursor_screen,
});
}
}
pub(super) fn measurement_readout(&self, index: usize) -> Option<MeasurementReadout> {
let tab = &self.tabs[index];
if let Some(result) = &tab.measurement.result {
return Some(result.clone());
}
if !tab.measurement.live_visible {
return None;
}
let text = tab
.active_cmd
.as_ref()?
.measurement_readout(tab.last_cursor_world)?;
Some(MeasurementReadout {
text,
anchor: tab.last_cursor_screen,
})
}
}
#[cfg(test)]
mod measurement_tests {
use super::*;
use crate::app::Message;
use crate::command::{all_registered_command_names, StepInput};
use crate::modules::{CadModule, ModuleEvent, RibbonItem};
use glam::DVec3;
fn app() -> OpenCADStudio {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
app.tabs[i].scene.selection.borrow_mut().view.vp_size = (800.0, 600.0);
app.snapper.snap_enabled = false;
app.snapper.grid_snap_on = false;
app.snapper.otrack_enabled = false;
app.ortho_mode = false;
app.polar_mode = false;
app.dyn_input = false;
app
}
fn finish_distance(app: &mut OpenCADStudio) {
let _ = app.dispatch_command("DIST");
let _ = app.feed_command(StepInput::Point(DVec3::ZERO));
let _ = app.feed_command(StepInput::Point(DVec3::new(3.0, 4.0, 0.0)));
}
#[test]
fn measurement_menu_registry_and_dispatch() {
let names = all_registered_command_names();
let module = crate::modules::draw::DrawModule;
let menu = module
.ribbon_groups()
.iter()
.flat_map(|g| &g.tools)
.find(|item| {
matches!(
item,
RibbonItem::LargeDropdown {
id: "MEASURE_MENU",
..
}
)
})
.unwrap();
let RibbonItem::LargeDropdown { items, default, .. } = menu else {
unreachable!()
};
assert_eq!(*default, "DIST");
assert_eq!(
items.iter().map(|item| item.0).collect::<Vec<_>>(),
["DIST", "MEASURERADIUS", "MEASUREANGLE", "AREA"]
);
let mut app = app();
for name in [
"DIST",
"MEASURERADIUS",
"MEASUREANGLE",
"AREA",
"MEASUREGEOM",
] {
assert!(names.contains(&name));
let _ = app.dispatch_command(name);
assert_eq!(
app.tabs[app.active_tab].active_cmd.as_ref().unwrap().name(),
name
);
let _ = app.update(Message::RibbonToolClick {
tool_id: name.into(),
event: ModuleEvent::Command(name.into()),
});
assert_eq!(
app.tabs[app.active_tab].active_cmd.as_ref().unwrap().name(),
name
);
}
}
#[test]
fn measurement_result_persists_without_document_changes() {
let mut app = app();
let i = app.active_tab;
let count = app.tabs[i].scene.document.entity_count();
let dirty = app.tabs[i].dirty;
let revision = app.tabs[i].edit_revision;
let _ = app.update(Message::ViewportMove(Point::new(350.0, 250.0)));
finish_distance(&mut app);
assert!(app.tabs[i].active_cmd.is_none());
let result = app.measurement_readout(i).unwrap();
assert!(app.command_line.history_plain_text().contains(&result.text));
let _ = app.update(Message::ViewportLeftRelease);
let _ = app.update(Message::ViewportMove(Point::new(450.0, 300.0)));
let retained = app.measurement_readout(i).unwrap();
assert_eq!(result.text, retained.text);
assert_eq!(result.anchor, retained.anchor);
assert_eq!(app.tabs[i].scene.document.entity_count(), count);
assert_eq!(app.tabs[i].dirty, dirty);
assert_eq!(app.tabs[i].edit_revision, revision);
}
#[test]
fn measurement_dismiss_click_also_starts_selection() {
let mut app = app();
let i = app.active_tab;
finish_distance(&mut app);
let _ = app.update(Message::ViewportMove(Point::new(350.0, 250.0)));
let _ = app.update(Message::ViewportLeftPress);
assert!(app.measurement_readout(i).is_none());
assert!(app.tabs[i].scene.selection.borrow().input.left_down);
let _ = app.update(Message::ViewportLeftRelease);
assert!(app.tabs[i]
.scene
.selection
.borrow()
.gesture
.box_anchor
.is_some());
}
#[test]
fn measurement_final_pick_survives_its_own_release() {
let mut app = app();
let i = app.active_tab;
let _ = app.dispatch_command("DIST");
for point in [Point::new(350.0, 250.0), Point::new(430.0, 280.0)] {
let _ = app.update(Message::ViewportMove(point));
let _ = app.update(Message::ViewportLeftPress);
let _ = app.update(Message::ViewportLeftRelease);
}
assert!(app.tabs[i].active_cmd.is_none());
assert!(app.measurement_readout(i).is_some());
}
#[test]
fn measurement_live_without_dyn_and_escape() {
let mut app = app();
let i = app.active_tab;
let _ = app.dispatch_command("DIST");
let _ = app.feed_command(StepInput::Point(DVec3::ZERO));
let _ = app.update(Message::ViewportMove(Point::new(410.0, 310.0)));
let first = app.measurement_readout(i).unwrap().text;
let _ = app.update(Message::ViewportMove(Point::new(510.0, 310.0)));
assert_ne!(first, app.measurement_readout(i).unwrap().text);
let _ = app.update(Message::CommandEscape);
assert!(app.measurement_readout(i).is_none());
}
#[test]
fn measurement_escape_new_command_and_mouse_buttons_clear_result() {
let mut app = app();
let i = app.active_tab;
for event in [
Message::CommandEscape,
Message::ViewportRightPress,
Message::ViewportMiddlePress,
] {
finish_distance(&mut app);
assert!(app.measurement_readout(i).is_some());
let _ = app.update(event);
assert!(app.measurement_readout(i).is_none());
}
finish_distance(&mut app);
let _ = app.dispatch_command("MEASURERADIUS");
assert!(app.measurement_readout(i).is_none());
}
#[test]
fn measurement_area_add_dismiss_click_is_also_next_corner() {
let mut app = app();
let i = app.active_tab;
let _ = app.dispatch_command("AREA");
let _ = app.feed_command(StepInput::Text("ADD".into()));
for p in [DVec3::ZERO, DVec3::X * 4.0, DVec3::Y * 3.0] {
let _ = app.feed_command(StepInput::Point(p));
}
let _ = app.feed_command(StepInput::Enter);
assert_eq!(app.tabs[i].active_cmd.as_ref().unwrap().name(), "AREA");
assert!(app.measurement_readout(i).is_some());
for p in [Point::new(350.0, 250.0), Point::new(410.0, 250.0)] {
let _ = app.update(Message::ViewportMove(p));
let _ = app.update(Message::ViewportLeftPress);
let _ = app.update(Message::ViewportLeftRelease);
}
assert!(app.tabs[i].measurement.result.is_none());
assert!(
app.measurement_readout(i).is_some(),
"both corners must reach AREA"
);
}
#[test]
fn measurement_radius_and_lines_real_entity_injection() {
use codec::{
entities::{Circle, Line},
types::Vector3,
EntityType,
};
let mut app = app();
let i = app.active_tab;
let circle = app.tabs[i]
.scene
.add_entity(EntityType::Circle(Circle::from_coords(0.0, 0.0, 0.0, 2.0)));
let first = app.tabs[i]
.scene
.add_entity(EntityType::Line(Line::from_points(
Vector3::ZERO,
Vector3::UNIT_X,
)));
let second = app.tabs[i]
.scene
.add_entity(EntityType::Line(Line::from_points(
Vector3::ZERO,
Vector3::UNIT_Y,
)));
let _ = app.dispatch_command("MEASURERADIUS");
let _ = app.feed_command(StepInput::EntityPick(circle, DVec3::X * 2.0));
assert!(app.tabs[i].active_cmd.is_none());
assert!(app.measurement_readout(i).unwrap().text.contains("Radius"));
let _ = app.dispatch_command("MEASUREANGLE");
let _ = app.feed_command(StepInput::Text("LINES".into()));
let _ = app.feed_command(StepInput::EntityPick(first, DVec3::X));
let _ = app.feed_command(StepInput::EntityPick(second, DVec3::Y));
assert!(app.tabs[i].active_cmd.is_none());
assert!(app.measurement_readout(i).unwrap().text.contains("90"));
}
}
+1
View File
@@ -29,6 +29,7 @@ mod find_replace;
pub(crate) mod helpers;
mod history;
mod layers;
mod measurement;
mod model_ops;
mod mtext_editor;
mod navigation;
+1
View File
@@ -1249,6 +1249,7 @@ impl OpenCADStudio {
}
pub(super) fn on_command_escape(&mut self) -> Task<Message> {
self.tabs[self.active_tab].measurement.clear();
// Escape answers a pending client getpoint request first: no point.
if self.control.get_point.is_some() {
self.resolve_get_point(None);
+1
View File
@@ -4564,6 +4564,7 @@ impl OpenCADStudio {
Message::ViewportRightPress => {
self.graphic_attributes.open_menu = None;
let i = self.active_tab;
self.tabs[i].measurement.dismiss_result();
self.ribbon.close_dropdown();
// Shift+RMB: the one-shot snap override menu at the cursor —
// pick a snap for just the next point, then it expires (#337).
+4
View File
@@ -1236,6 +1236,7 @@ impl OpenCADStudio {
return Task::none();
}
let i = self.active_tab;
self.tabs[i].measurement.live_visible = true;
if let Some(task) = self.start_drag_move(i, p) {
return Task::batch([task, self.on_viewport_move(p)]);
}
@@ -3162,6 +3163,7 @@ impl OpenCADStudio {
pub(crate) fn on_viewport_exit(&mut self) -> Task<Message> {
let i = self.active_tab;
self.tabs[i].measurement.live_visible = false;
self.hover_dwell = None;
if self.tabs[i]
.active_cmd
@@ -3658,6 +3660,7 @@ impl OpenCADStudio {
pub(super) fn on_viewport_left_press(&mut self) -> Task<Message> {
let i = self.active_tab;
self.tabs[i].measurement.dismiss_result();
self.block_palette.pressed = None;
if let Some(command) = self.tabs[i].active_cmd.as_mut() {
command.set_ctrl(self.ctrl_down);
@@ -5916,6 +5919,7 @@ properties={:.1}ms picked={}",
pub(super) fn on_viewport_middle_press(&mut self) -> Task<Message> {
let i = self.active_tab;
self.tabs[i].measurement.dismiss_result();
self.clear_navigation_hover(i);
self.ribbon.close_dropdown();
self.tabs[i].scene.remember_current_view();
+9
View File
@@ -1809,6 +1809,15 @@ bg={bg_ms:.1}ms n={view_count}"
} else {
34.0 + self.command_line.overlay_lines_height()
};
if !tab.is_start {
if let Some(readout) = self.measurement_readout(self.active_tab) {
viewport_stack = viewport_stack.push(overlay::measurement_overlay(
readout.anchor,
command_line_inset,
readout.text,
));
}
}
// Quick Properties: stay near the selection cursor, flipping around
// it as needed to remain inside the visible drawing area.
if self.quick_properties && !tab.is_start {
+119 -1
View File
@@ -928,6 +928,7 @@ fn position_canvas_overlay_clamped<'a>(
bottom_inset: bottom_inset.max(0.0),
gap: 0.0,
offset,
passive: false,
})
}
@@ -944,6 +945,48 @@ pub(super) fn position_canvas_overlay_near_cursor<'a>(
bottom_inset: bottom_inset.max(0.0),
gap: 12.0,
offset: Vector::ZERO,
passive: false,
})
}
/// Measured, wrapping text in the existing clamped panel layout. Input goes
/// straight through, including clicks directly on the result text.
pub(super) fn measurement_overlay<'a>(
anchor: iced::Point,
bottom_inset: f32,
value: String,
) -> Element<'a, Message> {
// Inquiry commands return a compact, comma-separated summary for the
// command line. Show its fields on separate lines in the transient HUD,
// without altering the command-line history or the measured precision.
let display_value = if value.contains("\n") {
value.replace(", ", "\n").replace(", ", "\n")
} else {
value
};
let panel = container(text(display_value).size(12))
.padding([6, 8])
.width(Length::Fit.max(300.0))
.style(|theme: &Theme| {
let palette = theme.palette();
container::Style {
background: Some(Background::Color(palette.background.weak.color)),
text_color: Some(palette.background.weak.text),
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
}
});
Element::new(ClampedPin {
content: panel.into(),
anchor,
bottom_inset: bottom_inset.max(0.0),
gap: 18.0,
offset: Vector::ZERO,
passive: true,
})
}
@@ -954,6 +997,8 @@ struct ClampedPin<'a> {
gap: f32,
/// Preferred placement shift from the anchor (applied before fitting).
offset: Vector,
/// Readouts prefer above the cursor and never participate in input.
passive: bool,
}
impl Widget<Message, Theme, iced::Renderer> for ClampedPin<'_> {
@@ -1008,7 +1053,9 @@ impl Widget<Message, Theme, iced::Renderer> for ClampedPin<'_> {
let max_y = (max.height - bottom_inset - MARGIN - content.height).max(MARGIN);
let below = self.anchor.y + self.gap + self.offset.y;
let above = self.anchor.y - self.gap - content.height;
let y = if below <= max_y {
let y = if self.passive && above >= MARGIN && above <= max_y {
above
} else if below <= max_y {
below.max(MARGIN)
} else if above >= MARGIN {
above
@@ -1016,6 +1063,9 @@ impl Widget<Message, Theme, iced::Renderer> for ClampedPin<'_> {
max_y
};
// A cursor over the command-line inset can put even the "above"
// candidate below the safe area. Keep this passive readout inside it.
let y = if self.passive { y.min(max_y) } else { y };
node = node.move_to(iced::Point::new(x, y));
let size = limits.resolve(Length::Fill, Length::Fill, node.size());
layout::Node::with_children(size, vec![node])
@@ -1046,6 +1096,9 @@ impl Widget<Message, Theme, iced::Renderer> for ClampedPin<'_> {
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
if self.passive {
return;
}
self.content.as_widget_mut().update(
tree,
event,
@@ -1065,6 +1118,9 @@ impl Widget<Message, Theme, iced::Renderer> for ClampedPin<'_> {
viewport: &Rectangle,
renderer: &iced::Renderer,
) -> mouse::Interaction {
if self.passive {
return mouse::Interaction::None;
}
self.content.as_widget().mouse_interaction(
tree,
layout.children().next().unwrap(),
@@ -1872,3 +1928,65 @@ fn qselect_content<'a>(
panel.into()
}
#[cfg(test)]
mod measurement_tests {
use super::*;
#[test]
fn measurement_overlay_passes_all_clicks_on_text() {
let underlay = mouse_area(Space::new().width(Fill).height(Fill))
.on_press(Message::ViewportLeftPress)
.on_right_press(Message::ViewportRightPress)
.on_middle_press(Message::ViewportMiddlePress);
let mut ui = iced_test::simulator(stack![
underlay,
measurement_overlay(
iced::Point::new(400.0, 300.0),
60.0,
"measurement-result".into()
)
]);
let center = ui
.find("measurement-result")
.unwrap()
.visible_bounds()
.unwrap()
.center();
ui.point_at(center);
for button in [
iced::mouse::Button::Left,
iced::mouse::Button::Right,
iced::mouse::Button::Middle,
] {
ui.simulate([Event::Mouse(iced::mouse::Event::ButtonPressed(button))]);
ui.simulate([Event::Mouse(iced::mouse::Event::ButtonReleased(button))]);
}
let messages: Vec<_> = ui.into_messages().collect();
assert!(messages
.iter()
.any(|m| matches!(m, Message::ViewportLeftPress)));
assert!(messages
.iter()
.any(|m| matches!(m, Message::ViewportRightPress)));
assert!(messages
.iter()
.any(|m| matches!(m, Message::ViewportMiddlePress)));
}
#[test]
fn measurement_overlay_stays_inside_canvas_above_command_line() {
for anchor in [iced::Point::new(5.0, 5.0), iced::Point::new(790.0, 595.0)] {
let mut ui = iced_test::simulator::Simulator::with_size(
Default::default(),
iced::Size::new(800.0, 600.0),
measurement_overlay(anchor, 90.0, "measurement-result".into()),
);
let bounds = ui
.find("measurement-result")
.unwrap()
.visible_bounds()
.unwrap();
assert!(bounds.x >= 4.0 && bounds.y >= 4.0);
assert!(bounds.x + bounds.width <= 796.0 && bounds.y + bounds.height <= 506.0);
}
}
}