diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 1824076db..01c918daf 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -836,6 +836,25 @@ impl OpenCADStudio { { let w = tab.last_cursor_world; let base = tab.dyn_anchor.or(self.last_point); + let label_screen = tab + .active_cmd + .as_ref() + .and_then(|command| command.dyn_label_point(w)) + .and_then(|world| { + let (vw, vh) = tab.scene.selection.borrow().vp_size; + let (camera, bounds) = tab + .scene + .viewport_edit_frame((vw, vh)) + .unwrap_or_else(|| { + ( + tab.scene.camera.borrow().clone(), + tab.scene.active_model_tile_bounds(vw, vh), + ) + }); + camera.project(world, bounds).map(|point| { + iced::Point::new(bounds.x + point.x, bounds.y + point.y) + }) + }); // A command may drive a typed scalar by mouse (e.g. a // perpendicular distance to a picked object); show that live // value in the box until the user types over it. @@ -898,6 +917,7 @@ impl OpenCADStudio { tab.last_cursor_screen, tab.last_point_screen, tab.dyn_ref_screen, + label_screen, tab.dyn_guide, boxes, prompt, diff --git a/src/command.rs b/src/command.rs index c9899e9d0..4aec1e3e3 100644 --- a/src/command.rs +++ b/src/command.rs @@ -2179,6 +2179,12 @@ pub trait CadCommand: Send { fn dyn_live_value(&self, _cursor: DVec3) -> Option { None } + + /// Optional world-space point where the dynamic value box should be + /// centred. The view projects it with the active camera. + fn dyn_label_point(&self, _cursor: DVec3) -> Option { + None + } } // ── Autocomplete registry ───────────────────────────────────────────────── diff --git a/src/modules/draw/modify/rotate.rs b/src/modules/draw/modify/rotate.rs index 2f98ee9a0..333326e80 100644 --- a/src/modules/draw/modify/rotate.rs +++ b/src/modules/draw/modify/rotate.rs @@ -73,6 +73,19 @@ impl RotateCommand { ) } + fn angle_arc( + center: [f64; 2], + radius: f64, + angle: f64, + ) -> Option<(geom2d::Arc, bool)> { + let (start, end, reverse) = if angle > 0.0 { + (0.0, angle, false) + } else { + (angle, 0.0, true) + }; + geom2d::bounded_arc(center, radius, start, end).map(|arc| (arc, reverse)) + } + fn angle_guide(&self, center: DVec3, radius: f64, angle: f64) -> Vec { if radius <= f64::EPSILON { return vec![]; @@ -95,13 +108,11 @@ impl RotateCommand { return guides; } - let (start, end, reverse) = if angle > 0.0 { - (0.0, angle, false) - } else { - (angle, 0.0, true) - }; - let Some(arc) = geom2d::bounded_arc([center_local.x, center_local.y], radius, start, end) - else { + let Some((arc, reverse)) = Self::angle_arc( + [center_local.x, center_local.y], + radius, + angle, + ) else { return guides; }; let mut points = KernelCurve::Arc(arc).tessellate_angle(TAU / 64.0); @@ -327,4 +338,54 @@ impl CadCommand for RotateCommand { _ => None, } } + + fn dyn_label_point(&self, cursor: DVec3) -> Option { + let center = match self.step { + Step::Angle { center } | Step::RefNew { center, .. } => center, + _ => return None, + }; + let center_local = self.plane.to_local(center); + let direction = self.plane.vector_to_local(cursor - center); + let radius = direction.truncate().length(); + if radius <= f64::EPSILON { + return None; + } + let angle = direction.y.atan2(direction.x); + let point = if angle.abs() <= f64::EPSILON { + [center_local.x + radius, center_local.y] + } else { + let (arc, _) = Self::angle_arc( + [center_local.x, center_local.y], + radius, + angle, + )?; + KernelCurve::Arc(arc).point_at(0.5) + }; + Some( + self.plane + .to_world(DVec3::new(point[0], point[1], center_local.z)), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn angle_label_uses_the_working_plane_arc_midpoint() { + let plane = WorkingPlane::new(DVec3::ZERO, DVec3::Y, DVec3::Z); + let mut command = RotateCommand::new(vec![], vec![]); + command.set_working_plane(plane); + command.step = Step::Angle { + center: DVec3::ZERO, + }; + + let label = command + .dyn_label_point(plane.y * 10.0) + .expect("label point"); + let expected = (plane.x + plane.y) * (50.0_f64).sqrt(); + + assert!(label.abs_diff_eq(expected, 1e-9)); + } } diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 265fee681..c186d4c6e 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -2301,6 +2301,7 @@ pub fn dynamic_input_overlay<'a>( cursor_screen: Point, base_screen: Option, ref_screen: Option, + label_screen: Option, guide: DynGuide, boxes: Vec, prompt: String, @@ -2310,6 +2311,7 @@ pub fn dynamic_input_overlay<'a>( cursor_screen, base_screen, ref_screen, + label_screen, guide, boxes, prompt, @@ -2327,6 +2329,8 @@ struct DynInputCanvas { base_screen: Option, /// Far end of the reference line (projected `dyn_ref`) — for `Perp`. ref_screen: Option, + /// Command-supplied world-space label point projected by the active camera. + label_screen: Option, guide: DynGuide, boxes: Vec, /// The active command's current prompt, drawn just above the boxes. @@ -2359,21 +2363,6 @@ impl DynInputCanvas { (Self::box_content(b).len() as f32 * DYN_CHAR_W) + DYN_PAD * 2.0 } - fn box_center_near_cursor(b: &DynBox, cursor: Point, bounds: iced::Rectangle) -> Point { - let width = Self::box_width(b); - let x = if cursor.x + DYN_OFFSET_X + width <= bounds.width { - cursor.x + DYN_OFFSET_X + width * 0.5 - } else { - cursor.x - DYN_OFFSET_X - width * 0.5 - }; - let y = if cursor.y + DYN_OFFSET_X + DYN_BOX_H <= bounds.height { - cursor.y + DYN_OFFSET_X + DYN_BOX_H * 0.5 - } else { - cursor.y - DYN_OFFSET_X - DYN_BOX_H * 0.5 - }; - Point { x, y } - } - /// Draw a value box centred at `center`, clamped inside `bounds`. fn draw_box( frame: &mut canvas::Frame, @@ -2660,7 +2649,14 @@ impl DynInputCanvas { // ── Box placement by role ── for b in &self.boxes { let center = match b.role { - DynRole::Angle => Self::box_center_near_cursor(b, cursor_raw, bounds), + DynRole::Angle => self.label_screen.unwrap_or_else(|| { + let a_mid = a_ref + sweep * 0.5; + let r = (len - DYN_BOX_H * 2.0).max(len * 0.5); + Point { + x: base.x + a_mid.cos() * r - nx * 18.0, + y: base.y + a_mid.sin() * r - ny * 18.0, + } + }), DynRole::X | DynRole::Width => Point { x: (base.x + cursor.x) * 0.5, y: base.y + 14.0, @@ -2854,6 +2850,9 @@ impl canvas::Program for DynInputCanvas { // Guided layouts need the anchor; without it fall back to a cursor row. match (self.guide, self.base_screen) { + (DynGuide::None, Some(base)) if self.label_screen.is_some() => { + self.draw_guided(&mut frame, bounds, base, theme) + } (DynGuide::None, _) | (_, None) => self.draw_row(&mut frame, bounds, theme), (_, Some(base)) => self.draw_guided(&mut frame, bounds, base, theme), } @@ -2861,40 +2860,6 @@ impl canvas::Program for DynInputCanvas { } } -#[cfg(test)] -mod dynamic_input_position_tests { - use super::*; - - #[test] - fn angle_box_follows_cursor_and_flips_inside_viewport_edges() { - let angle = DynBox { - label: String::new(), - value: "163.7".to_string(), - active: true, - locked: false, - role: DynRole::Angle, - }; - let bounds = iced::Rectangle { - x: 0.0, - y: 0.0, - width: 800.0, - height: 600.0, - }; - let width = DynInputCanvas::box_width(&angle); - - let cursor = Point::new(400.0, 300.0); - let center = DynInputCanvas::box_center_near_cursor(&angle, cursor, bounds); - assert!((center.x - width * 0.5 - cursor.x - DYN_OFFSET_X).abs() < 0.01); - assert!((center.y - DYN_BOX_H * 0.5 - cursor.y - DYN_OFFSET_X).abs() < 0.01); - - let cursor = Point::new(795.0, 595.0); - let center = DynInputCanvas::box_center_near_cursor(&angle, cursor, bounds); - assert!(center.x + width * 0.5 <= bounds.width); - assert!(center.y + DYN_BOX_H * 0.5 <= bounds.height); - assert!(center.x < cursor.x && center.y < cursor.y); - } -} - #[cfg(test)] mod clip_tests { use super::*;