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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-15 23:03:08 +03:00
1 parent 40a52cea79
commit 1a9844b134
6 files changed
+137 -66

No files matched your search

+4 -4
View File
@@ -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 {
+44 -33
View File
@@ -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
}
}
}