diff --git a/src/app/update.rs b/src/app/update.rs index 8c90452dc..96223a3d1 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -7964,6 +7964,47 @@ impl OpenCADStudio { /// the field buffers, and return the resulting task. Returns `None` /// when there is nothing typed, so the caller falls back to its normal /// Enter handling. + /// Give a bare typed angle the sign of the cursor's side relative to the + /// step's reference direction, so a commit-as-text angle rotates/sweeps the + /// way the cursor is dragging. Only applies to steps with an `Angle` field; + /// an explicit `+`/`-` is left untouched. Returns the (possibly re-signed) + /// text to feed `on_text_input`. + fn dyn_sign_angle_text(&self, i: usize, text: String) -> String { + let has_angle = self.tabs[i] + .dyn_fields + .iter() + .any(|f| f.role == crate::command::DynRole::Angle); + let t = text.trim(); + if !has_angle || t.is_empty() || t.starts_with('-') || t.starts_with('+') { + return text; + } + if t.parse::().is_err() { + return text; + } + let anchor = self.tabs[i] + .dyn_anchor + .or(self.last_point) + .unwrap_or(glam::Vec3::ZERO); + let cur = self.tabs[i].last_cursor_world; + let a_cur = (cur.y - anchor.y).atan2(cur.x - anchor.x); + let a_ref = self.tabs[i] + .dyn_ref + .map(|r| (r.y - anchor.y).atan2(r.x - anchor.x)) + .unwrap_or(0.0); + let mut d = a_cur - a_ref; + while d > std::f32::consts::PI { + d -= std::f32::consts::TAU; + } + while d <= -std::f32::consts::PI { + d += std::f32::consts::TAU; + } + if d < 0.0 { + format!("-{t}") + } else { + text + } + } + fn try_dyn_commit(&mut self) -> Option> { let i = self.active_tab; if !self.dyn_input @@ -8026,6 +8067,11 @@ impl OpenCADStudio { .find_map(|f| f.buffer.clone()) .unwrap_or_default(); let text = super::expr_eval::eval_to_string(text.trim()); + // Shared rule: a bare angle typed into a commit-as-text step takes + // the sign of the cursor's side relative to the reference, so the + // committed direction matches the drag (the box shows magnitude + // only). Commands receive an already-signed string. + let text = self.dyn_sign_angle_text(i, text); let result = self.tabs[i] .active_cmd .as_mut() diff --git a/src/app/view.rs b/src/app/view.rs index c3ab020f2..2c8a12b98 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -718,17 +718,6 @@ impl OpenCADStudio { // perpendicular distance to a picked object); show that live // value in the box until the user types over it. let live = tab.active_cmd.as_ref().and_then(|c| c.dyn_live_value(w)); - // A Distance+Angle set is polar point entry; its angle box is - // shown unsigned to match the overlay's short arc. A standalone - // angle (ROTATE) keeps absolute CCW. - let polar_pair = tab - .dyn_fields - .iter() - .any(|f| f.component == DynComponent::Distance) - && tab - .dyn_fields - .iter() - .any(|f| f.component == DynComponent::Angle); let boxes: Vec = tab .dyn_fields .iter() @@ -749,7 +738,7 @@ impl OpenCADStudio { { format!("{lv:.4}") } - _ => dyn_component_value(f, w, base, polar_pair), + _ => dyn_component_value(f, w, base), }; overlay::DynBox { label: f.role.label().to_string(), @@ -4073,12 +4062,7 @@ fn render_mode_picker<'a>(current: acadrust::entities::ViewportRenderMode) -> El /// The string shown inside a dynamic-input box: the typed buffer when the /// field is locked, otherwise the live value derived from the cursor /// world position (and the base point for polar quantities). -fn dyn_component_value( - f: &DynFieldEntry, - w: glam::Vec3, - base: Option, - polar_pair: bool, -) -> String { +fn dyn_component_value(f: &DynFieldEntry, w: glam::Vec3, base: Option) -> String { if let Some(b) = &f.buffer { return b.clone(); } @@ -4104,11 +4088,12 @@ fn dyn_component_value( DynComponent::Distance => { format!("{:.4}", (dx * dx + dy * dy).sqrt() * f.role.value_scale() as f64) } - // Polar point entry (Distance+Angle): unsigned [0,180] to match the - // short arc — above or below the X axis both read e.g. 30°. - DynComponent::Angle if polar_pair => format!("{:.1}", dy.atan2(dx).to_degrees().abs()), - // Standalone angle (ROTATE): absolute CCW 0..360, as before. - DynComponent::Angle => format!("{:.1}", dy.atan2(dx).to_degrees().rem_euclid(360.0)), + // Shared rule: unsigned magnitude of the short angle, so CW (below the + // reference axis) reads positive (e.g. 30°, not -30°/330°). The + // committed value stays signed (see dyn_resolve_point). + DynComponent::Angle => { + format!("{:.1}", crate::command::dyn_display_angle_deg(dy.atan2(dx) as f32)) + } // Typed-only scalar — no geometric value to track when empty. DynComponent::Scalar => String::new(), } diff --git a/src/command/mod.rs b/src/command/mod.rs index bd0621f9f..8abb119ce 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -283,6 +283,23 @@ pub enum DynRole { Count, } +/// Shared rule for how an angle reads in the dynamic-input box: the unsigned +/// magnitude of the short signed angle, so a clockwise angle (cursor below the +/// reference axis) shows as a positive value rather than a negative or a +/// CCW 300-something. Callers keep the *signed* radian for the actual +/// computation/commit; this is display-only. `signed_rad` is the angle from +/// the reference to the cursor. +pub fn dyn_display_angle_deg(signed_rad: f32) -> f32 { + let mut a = signed_rad % std::f32::consts::TAU; + if a > std::f32::consts::PI { + a -= std::f32::consts::TAU; + } + if a <= -std::f32::consts::PI { + a += std::f32::consts::TAU; + } + a.to_degrees().abs() +} + impl DynRole { /// Default label shown before the value (empty = value only). pub fn label(self) -> &'static str { diff --git a/src/modules/home/draw/arc.rs b/src/modules/home/draw/arc.rs index 8b8be8b48..b7a1ff466 100644 --- a/src/modules/home/draw/arc.rs +++ b/src/modules/home/draw/arc.rs @@ -582,7 +582,7 @@ impl CadCommand for ArcSCACommand { anchor: DynAnchor::Point(self.c), fields: vec![DynFieldSpec::new(DynRole::Angle)], guide: DynGuide::Polar, - ref_point: None, + ref_point: Some(self.c + Vec3::new(self.sa.cos(), self.sa.sin(), 0.0)), }) } fn dyn_commit_as_text(&self) -> bool { @@ -590,7 +590,7 @@ impl CadCommand for ArcSCACommand { } fn dyn_live_value(&self, cursor: Vec3) -> Option { (self.step == 2) - .then(|| (angle_xy(self.c, cursor) - self.sa).to_degrees().rem_euclid(360.0) as f64) + .then(|| crate::command::dyn_display_angle_deg(angle_xy(self.c, cursor) - self.sa) as f64) } fn on_mouse_move(&mut self, pt: Vec3) -> Option { match self.step { @@ -1074,7 +1074,7 @@ impl CadCommand for ArcCSACommand { anchor: DynAnchor::Point(self.c), fields: vec![DynFieldSpec::new(DynRole::Angle)], guide: DynGuide::Polar, - ref_point: None, + ref_point: Some(self.c + Vec3::new(self.sa.cos(), self.sa.sin(), 0.0)), }) } fn dyn_commit_as_text(&self) -> bool { @@ -1082,7 +1082,7 @@ impl CadCommand for ArcCSACommand { } fn dyn_live_value(&self, cursor: Vec3) -> Option { (self.step == 2) - .then(|| (angle_xy(self.c, cursor) - self.sa).to_degrees().rem_euclid(360.0) as f64) + .then(|| crate::command::dyn_display_angle_deg(angle_xy(self.c, cursor) - self.sa) as f64) } fn on_mouse_move(&mut self, pt: Vec3) -> Option { match self.step { diff --git a/src/modules/home/modify/rotate.rs b/src/modules/home/modify/rotate.rs index c0a708b7f..2aad02b16 100644 --- a/src/modules/home/modify/rotate.rs +++ b/src/modules/home/modify/rotate.rs @@ -121,6 +121,8 @@ impl CadCommand for RotateCommand { fn on_text_input(&mut self, text: &str) -> Option { if let Step::Angle { center, .. } = &self.step { + // The value already carries the correct sign (the dynamic-input + // layer applies the cursor's side for a bare magnitude). let deg: f32 = text.trim().replace(',', ".").parse().ok()?; let center = *center; defaults::set_rotate_angle(deg); @@ -151,41 +153,17 @@ impl CadCommand for RotateCommand { }; let dest_angle = (pt.y - center.y).atan2(pt.x - center.x); let angle_rad = dest_angle - ref_angle; - // Object ghosts rotated to new angle. - let mut out: Vec = self - .wire_models + // Track the live SIGNED rotation (relative to the reference) so that + // committing with Enter rotates the way the cursor is dragging — the + // dynamic-input box shows the unsigned magnitude, but the committed + // value must keep its direction (clockwise = negative). + self.default_angle = angle_rad.to_degrees(); + // Object ghosts rotated to the new angle. The rotation sweep arc is + // drawn by the dynamic-input overlay (polar guide), not here. + self.wire_models .iter() .map(|w| w.rotated(center, angle_rad)) - .collect(); - // Arc rubber-band showing the rotation sweep. - let r = center.distance(pt).max(0.3); - let mut end = dest_angle; - if end < ref_angle { - end += std::f32::consts::TAU; - } - let span = end - ref_angle; - let segs = ((span.abs() / std::f32::consts::TAU) * 48.0) - .ceil() - .max(4.0) as u32; - let mut arc_pts: Vec<[f32; 3]> = (0..=segs) - .map(|i| { - let a = ref_angle + span * (i as f32 / segs as f32); - [center.x + r * a.cos(), center.y + r * a.sin(), center.z] - }) - .collect(); - arc_pts.push([center.x, center.y, center.z]); - arc_pts.push([ - center.x + r * ref_angle.cos(), - center.y + r * ref_angle.sin(), - center.z, - ]); - out.push(WireModel::solid( - "rubber_band".into(), - arc_pts, - WireModel::CYAN, - false, - )); - out + .collect() } fn dyn_field(&self) -> DynField { @@ -194,4 +172,37 @@ impl CadCommand for RotateCommand { _ => DynField::Point, } } + + fn dyn_spec(&self) -> Option { + use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec}; + // Rotation angle is measured about the CENTRE, swept from the reference + // direction. The polar guide arc is anchored at the centre and starts + // at the reference (via ref_point), with the value box centred on it. + if let Step::Angle { center, ref_angle } = self.step { + let ref_dir = Vec3::new(center.x + ref_angle.cos(), center.y + ref_angle.sin(), center.z); + Some(DynSpec { + anchor: DynAnchor::Point(center), + fields: vec![DynFieldSpec::new(DynRole::Angle)], + guide: DynGuide::Polar, + ref_point: Some(ref_dir), + }) + } else { + None + } + } + + fn dyn_commit_as_text(&self) -> bool { + matches!(self.step, Step::Angle { .. }) + } + + fn dyn_live_value(&self, cursor: Vec3) -> Option { + // The rotation amount = cursor direction from the centre minus the + // reference angle, so the box reads the actual rotation. + if let Step::Angle { center, ref_angle } = &self.step { + let dest = (cursor.y - center.y).atan2(cursor.x - center.x); + Some(crate::command::dyn_display_angle_deg(dest - ref_angle) as f64) + } else { + None + } + } } diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index dfe2d5d8b..982910071 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -1344,12 +1344,19 @@ impl DynInputCanvas { nx = -nx; ny = -ny; } - // Short-way screen sweep from +X to the line, for the angle arc. - let mut sweep = dy.atan2(dx); + // Polar arc reference direction: a supplied reference point (e.g. the + // ROTATE reference), else the +X axis. The arc sweeps the short way + // from that reference to the cursor. + let a_cur = dy.atan2(dx); + let a_ref = self + .ref_screen + .map(|r| (r.y - base.y).atan2(r.x - base.x)) + .unwrap_or(0.0); + let mut sweep = a_cur - a_ref; while sweep > std::f32::consts::PI { sweep -= std::f32::consts::TAU; } - while sweep < -std::f32::consts::PI { + while sweep <= -std::f32::consts::PI { sweep += std::f32::consts::TAU; } let corner = Point { x: cursor.x, y: base.y }; // axis-delta elbow @@ -1373,15 +1380,20 @@ impl DynInputCanvas { // ── Guide geometry ── match self.guide { DynGuide::Polar => { + // Reference line along `a_ref` (the +X axis, or the supplied + // reference direction), then the arc from it to the cursor. let href = canvas::Path::new(|p| { p.move_to(base); - p.line_to(Point { x: base.x + len, y: base.y }); + p.line_to(Point { + x: base.x + a_ref.cos() * len, + y: base.y + a_ref.sin() * len, + }); }); frame.stroke(&href, Self::dotted()); let arc = canvas::Path::new(|p| { let steps = 48; for k in 0..=steps { - let a = sweep * (k as f32 / steps as f32); + let a = a_ref + sweep * (k as f32 / steps as f32); let pt = Point { x: base.x + a.cos() * len, y: base.y + a.sin() * len, @@ -1455,7 +1467,7 @@ impl DynInputCanvas { for b in &self.boxes { let center = match b.role { DynRole::Angle => { - let a_mid = sweep * 0.5; + let a_mid = a_ref + sweep * 0.5; Point { x: base.x + a_mid.cos() * len, y: base.y + a_mid.sin() * len,