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:
parent
40a52cea79
commit
1a9844b134
6 changed files with 137 additions and 66 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<f64> {
|
||||
(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<WireModel> {
|
||||
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<f64> {
|
||||
(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<WireModel> {
|
||||
match self.step {
|
||||
|
|
|
|||
|
|
@ -121,6 +121,8 @@ impl CadCommand for RotateCommand {
|
|||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
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<WireModel> = 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<crate::command::DynSpec> {
|
||||
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<f64> {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue