feat(dynamic-input): shared angle reference + sign handling

Make angle display, sign and the polar guide reference part of the
common infrastructure instead of per-command logic:

- DynSpec.ref_point now drives the polar guide's reference direction, so
  the arc and value box can sweep from a command-defined reference (not
  just +X). LINE etc. keep +X by leaving it None.
- `dyn_display_angle_deg` is the single rule for how an angle reads: the
  unsigned magnitude of the short angle, so clockwise (cursor on the CW
  side of the reference) shows positive. Used by the generic value box,
  ROTATE and ARC.
- A typed angle committed via on_text_input is re-signed by the cursor's
  side relative to the reference (`dyn_sign_angle_text`), so commands
  receive an already-signed value and don't each implement it.

ROTATE: drop its own sweep arc (the polar guide draws it now, anchored at
the centre and swept from the reference); the rotation commits in the
dragged direction whether picked, typed or accepted with Enter.

ARC SCA/CSA: reference the start angle so the arc, sign and readout are
all relative to it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-15 23:03:08 +03:00
commit 1a9844b134
6 changed files with 137 additions and 66 deletions

View file

@ -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::<f32>().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<Task<Message>> {
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()

View file

@ -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<overlay::DynBox> = 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<glam::Vec3>,
polar_pair: bool,
) -> String {
fn dyn_component_value(f: &DynFieldEntry, w: glam::Vec3, base: Option<glam::Vec3>) -> 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(),
}