fix(dyn-input): support cartesian coords via "," (#35)
Typing \`,\` while DYN was on appended the character to the focused field's buffer (treated as a European decimal point), so users couldn't switch to cartesian coordinates mid-command. The dynamic input was stuck in polar \`[Distance, Angle]\` whenever a base point existed. \`,\` now acts as the coordinate separator (matching the AutoCAD convention): pressing it locks the current field's buffer, then either advances within the existing cartesian set or reshapes a polar configuration into cartesian: - \`[Distance, Angle]\` + \`,\` on the first field → \`[X(buf), Y]\` - \`[X, Y]\` + \`,\` on Y → \`[X, Y, Z]\` - otherwise advances to the next field (Tab-equivalent) \`sync_dyn_fields\` accepts both polar and cartesian shapes as valid \`Point\` configurations so a mouse-move after the reshape doesn't revert to polar. \`dyn_resolve_point\` and the overlay's live-value formatter treat cartesian inputs as RELATIVE to the base point when one exists — matching the DYN-on relative-coords convention from #26. \`DynComponent::Z\` plus its label / value paths fill out the 3-D variant. Closes #35 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
4c60dce81a
commit
f952fc9bb7
3 changed files with 125 additions and 4 deletions
|
|
@ -20,6 +20,9 @@ pub(super) enum DynComponent {
|
||||||
X,
|
X,
|
||||||
/// Absolute Y ordinate.
|
/// Absolute Y ordinate.
|
||||||
Y,
|
Y,
|
||||||
|
/// Absolute Z ordinate (only visible after the user types a second
|
||||||
|
/// `,` separator from a cartesian X/Y configuration).
|
||||||
|
Z,
|
||||||
/// Linear distance from the last point.
|
/// Linear distance from the last point.
|
||||||
Distance,
|
Distance,
|
||||||
/// Angle from the last point, in degrees.
|
/// Angle from the last point, in degrees.
|
||||||
|
|
|
||||||
|
|
@ -883,13 +883,27 @@ impl OpenCADStudio {
|
||||||
// glyph(s). `Tab`, etc. arrive as Named keys, not here.
|
// glyph(s). `Tab`, etc. arrive as Named keys, not here.
|
||||||
if s.chars().all(|c| !c.is_control()) {
|
if s.chars().all(|c| !c.is_control()) {
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
|
// `,` is the coordinate separator in dynamic input,
|
||||||
|
// not a decimal point: typing it locks the current
|
||||||
|
// field's buffer and advances to the next coordinate,
|
||||||
|
// reshaping the field set when going polar → cartesian
|
||||||
|
// (Distance → X, Y) or 2-D → 3-D (X, Y → X, Y, Z).
|
||||||
|
// See #35.
|
||||||
|
if s == ","
|
||||||
|
&& self.dyn_input
|
||||||
|
&& !self.tabs[i].dyn_fields.is_empty()
|
||||||
|
{
|
||||||
|
self.dyn_comma_advance();
|
||||||
|
self.command_line.autocomplete_cursor = None;
|
||||||
|
return self.focus_cmd_input();
|
||||||
|
}
|
||||||
// While dynamic input is showing fields, numeric
|
// While dynamic input is showing fields, numeric
|
||||||
// glyphs edit the focused field instead of the
|
// glyphs edit the focused field instead of the
|
||||||
// command line. Letters still go to the command line
|
// command line. Letters still go to the command line
|
||||||
// so command-option keywords keep working.
|
// so command-option keywords keep working.
|
||||||
let numeric = !s.is_empty()
|
let numeric = !s.is_empty()
|
||||||
&& s.chars()
|
&& s.chars()
|
||||||
.all(|c| c.is_ascii_digit() || matches!(c, '.' | ',' | '-' | '+'));
|
.all(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+'));
|
||||||
if numeric && self.dyn_input && !self.tabs[i].dyn_fields.is_empty() {
|
if numeric && self.dyn_input && !self.tabs[i].dyn_fields.is_empty() {
|
||||||
let a = self.tabs[i].dyn_active.min(self.tabs[i].dyn_fields.len() - 1);
|
let a = self.tabs[i].dyn_active.min(self.tabs[i].dyn_fields.len() - 1);
|
||||||
self.tabs[i].dyn_fields[a]
|
self.tabs[i].dyn_fields[a]
|
||||||
|
|
@ -5370,7 +5384,7 @@ impl OpenCADStudio {
|
||||||
.map(|c| c.dyn_field())
|
.map(|c| c.dyn_field())
|
||||||
.unwrap_or(crate::command::DynField::Point);
|
.unwrap_or(crate::command::DynField::Point);
|
||||||
let has_base = self.last_point.is_some();
|
let has_base = self.last_point.is_some();
|
||||||
let desired: Vec<DynComponent> = match field {
|
let default: Vec<DynComponent> = match field {
|
||||||
crate::command::DynField::Distance => vec![DynComponent::Distance],
|
crate::command::DynField::Distance => vec![DynComponent::Distance],
|
||||||
crate::command::DynField::Angle => vec![DynComponent::Angle],
|
crate::command::DynField::Angle => vec![DynComponent::Angle],
|
||||||
crate::command::DynField::Point if has_base => {
|
crate::command::DynField::Point if has_base => {
|
||||||
|
|
@ -5378,10 +5392,30 @@ impl OpenCADStudio {
|
||||||
}
|
}
|
||||||
crate::command::DynField::Point => vec![DynComponent::X, DynComponent::Y],
|
crate::command::DynField::Point => vec![DynComponent::X, DynComponent::Y],
|
||||||
};
|
};
|
||||||
|
// Multiple shapes can satisfy the same command request — e.g. a
|
||||||
|
// `Point` is happy with either `[Distance, Angle]` (polar) or
|
||||||
|
// `[X, Y]` / `[X, Y, Z]` (cartesian). If the user already
|
||||||
|
// reshaped via `,` (see #35) the existing set is still a valid
|
||||||
|
// Point configuration and must not be reverted on every mouse
|
||||||
|
// move.
|
||||||
let current: Vec<DynComponent> =
|
let current: Vec<DynComponent> =
|
||||||
self.tabs[i].dyn_fields.iter().map(|f| f.component).collect();
|
self.tabs[i].dyn_fields.iter().map(|f| f.component).collect();
|
||||||
if current != desired {
|
let current_is_acceptable = match field {
|
||||||
self.tabs[i].dyn_fields = desired.into_iter().map(DynFieldEntry::new).collect();
|
crate::command::DynField::Distance => {
|
||||||
|
matches!(current.as_slice(), [DynComponent::Distance])
|
||||||
|
}
|
||||||
|
crate::command::DynField::Angle => {
|
||||||
|
matches!(current.as_slice(), [DynComponent::Angle])
|
||||||
|
}
|
||||||
|
crate::command::DynField::Point => matches!(
|
||||||
|
current.as_slice(),
|
||||||
|
[DynComponent::Distance, DynComponent::Angle]
|
||||||
|
| [DynComponent::X, DynComponent::Y]
|
||||||
|
| [DynComponent::X, DynComponent::Y, DynComponent::Z]
|
||||||
|
),
|
||||||
|
};
|
||||||
|
if !current_is_acceptable {
|
||||||
|
self.tabs[i].dyn_fields = default.into_iter().map(DynFieldEntry::new).collect();
|
||||||
self.tabs[i].dyn_active = 0;
|
self.tabs[i].dyn_active = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5431,10 +5465,29 @@ impl OpenCADStudio {
|
||||||
let live_d = (dx * dx + dy * dy).sqrt();
|
let live_d = (dx * dx + dy * dy).sqrt();
|
||||||
let live_a = dy.atan2(dx); // radians
|
let live_a = dy.atan2(dx); // radians
|
||||||
let comps: Vec<DynComponent> = fields.iter().map(|f| f.component).collect();
|
let comps: Vec<DynComponent> = fields.iter().map(|f| f.component).collect();
|
||||||
|
// DYN-on defaults to RELATIVE coordinates when a base point is set
|
||||||
|
// (see #26 / #35). The live cartesian fallback is the cursor
|
||||||
|
// position relative to base; typed values are relative deltas.
|
||||||
|
let has_base = self.last_point.is_some();
|
||||||
match comps.as_slice() {
|
match comps.as_slice() {
|
||||||
|
[DynComponent::X, DynComponent::Y] if has_base => {
|
||||||
|
Some(glam::Vec3::new(base.x + val(0, dx), base.y + val(1, dy), base.z))
|
||||||
|
}
|
||||||
[DynComponent::X, DynComponent::Y] => {
|
[DynComponent::X, DynComponent::Y] => {
|
||||||
Some(glam::Vec3::new(val(0, w.x), val(1, w.y), base.z))
|
Some(glam::Vec3::new(val(0, w.x), val(1, w.y), base.z))
|
||||||
}
|
}
|
||||||
|
[DynComponent::X, DynComponent::Y, DynComponent::Z] if has_base => {
|
||||||
|
Some(glam::Vec3::new(
|
||||||
|
base.x + val(0, dx),
|
||||||
|
base.y + val(1, dy),
|
||||||
|
base.z + val(2, 0.0),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
[DynComponent::X, DynComponent::Y, DynComponent::Z] => Some(glam::Vec3::new(
|
||||||
|
val(0, w.x),
|
||||||
|
val(1, w.y),
|
||||||
|
val(2, base.z),
|
||||||
|
)),
|
||||||
[DynComponent::Distance, DynComponent::Angle] => {
|
[DynComponent::Distance, DynComponent::Angle] => {
|
||||||
let d = val(0, live_d);
|
let d = val(0, live_d);
|
||||||
let a = val(1, live_a.to_degrees()).to_radians();
|
let a = val(1, live_a.to_degrees()).to_radians();
|
||||||
|
|
@ -5458,6 +5511,61 @@ impl OpenCADStudio {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle `,` while a dynamic-input field set is showing. Locks the
|
||||||
|
/// current field's buffer if it has one, then either advances within
|
||||||
|
/// the existing field set or reshapes it: a polar `[Distance, Angle]`
|
||||||
|
/// configuration becomes cartesian `[X(buf), Y]`, and a cartesian
|
||||||
|
/// `[X, Y]` configuration extends to `[X, Y, Z]`. Default fallthrough
|
||||||
|
/// is "advance to next field", matching `Tab`. See #35.
|
||||||
|
fn dyn_comma_advance(&mut self) {
|
||||||
|
use super::document::{DynComponent, DynFieldEntry};
|
||||||
|
let i = self.active_tab;
|
||||||
|
if self.tabs[i].dyn_fields.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let active = self
|
||||||
|
.tabs[i]
|
||||||
|
.dyn_active
|
||||||
|
.min(self.tabs[i].dyn_fields.len() - 1);
|
||||||
|
let comps: Vec<DynComponent> = self
|
||||||
|
.tabs[i]
|
||||||
|
.dyn_fields
|
||||||
|
.iter()
|
||||||
|
.map(|f| f.component)
|
||||||
|
.collect();
|
||||||
|
let cur_buf = self.tabs[i].dyn_fields[active].buffer.clone();
|
||||||
|
match (comps.as_slice(), active) {
|
||||||
|
// First polar field — `,` switches to cartesian, locking the
|
||||||
|
// typed value as X.
|
||||||
|
([DynComponent::Distance, DynComponent::Angle], 0)
|
||||||
|
| ([DynComponent::Distance], 0) => {
|
||||||
|
let mut x_field = DynFieldEntry::new(DynComponent::X);
|
||||||
|
x_field.buffer = cur_buf;
|
||||||
|
self.tabs[i].dyn_fields =
|
||||||
|
vec![x_field, DynFieldEntry::new(DynComponent::Y)];
|
||||||
|
self.tabs[i].dyn_active = 1;
|
||||||
|
}
|
||||||
|
// Already cartesian X (first field) — just advance to Y.
|
||||||
|
([DynComponent::X, DynComponent::Y], 0)
|
||||||
|
| ([DynComponent::X, DynComponent::Y, DynComponent::Z], 0) => {
|
||||||
|
self.tabs[i].dyn_active = 1;
|
||||||
|
}
|
||||||
|
// Cartesian Y — extend to 3-D by appending Z.
|
||||||
|
([DynComponent::X, DynComponent::Y], 1) => {
|
||||||
|
self.tabs[i]
|
||||||
|
.dyn_fields
|
||||||
|
.push(DynFieldEntry::new(DynComponent::Z));
|
||||||
|
self.tabs[i].dyn_active = 2;
|
||||||
|
}
|
||||||
|
// Cartesian Y in the 3-D set — advance to Z.
|
||||||
|
([DynComponent::X, DynComponent::Y, DynComponent::Z], 1) => {
|
||||||
|
self.tabs[i].dyn_active = 2;
|
||||||
|
}
|
||||||
|
// Z, Angle, or any singleton: nothing further to advance to.
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// If dynamic input has at least one locked (typed) field, resolve the
|
/// If dynamic input has at least one locked (typed) field, resolve the
|
||||||
/// implied point, feed it to the active command as a point pick, reset
|
/// implied point, feed it to the active command as a point pick, reset
|
||||||
/// the field buffers, and return the resulting task. Returns `None`
|
/// the field buffers, and return the resulting task. Returns `None`
|
||||||
|
|
|
||||||
|
|
@ -2124,6 +2124,7 @@ fn dyn_component_label(c: DynComponent) -> String {
|
||||||
match c {
|
match c {
|
||||||
DynComponent::X => "X".into(),
|
DynComponent::X => "X".into(),
|
||||||
DynComponent::Y => "Y".into(),
|
DynComponent::Y => "Y".into(),
|
||||||
|
DynComponent::Z => "Z".into(),
|
||||||
DynComponent::Distance => "d".into(),
|
DynComponent::Distance => "d".into(),
|
||||||
DynComponent::Angle => "<".into(),
|
DynComponent::Angle => "<".into(),
|
||||||
}
|
}
|
||||||
|
|
@ -2143,9 +2144,18 @@ fn dyn_component_value(
|
||||||
let b = base.unwrap_or(glam::Vec3::ZERO);
|
let b = base.unwrap_or(glam::Vec3::ZERO);
|
||||||
let dx = (w.x - b.x) as f64;
|
let dx = (w.x - b.x) as f64;
|
||||||
let dy = (w.y - b.y) as f64;
|
let dy = (w.y - b.y) as f64;
|
||||||
|
// When a base point exists (DYN-on after the first pick) the cartesian
|
||||||
|
// fields show relative deltas — matching the typed-value convention
|
||||||
|
// in `dyn_resolve_point` so the live preview and the committed
|
||||||
|
// coordinate use the same frame. See #35.
|
||||||
|
let has_base = base.is_some();
|
||||||
match f.component {
|
match f.component {
|
||||||
|
DynComponent::X if has_base => format!("{:.4}", dx),
|
||||||
|
DynComponent::Y if has_base => format!("{:.4}", dy),
|
||||||
|
DynComponent::Z if has_base => "0.0000".to_string(),
|
||||||
DynComponent::X => format!("{:.4}", w.x),
|
DynComponent::X => format!("{:.4}", w.x),
|
||||||
DynComponent::Y => format!("{:.4}", w.y),
|
DynComponent::Y => format!("{:.4}", w.y),
|
||||||
|
DynComponent::Z => format!("{:.4}", b.z),
|
||||||
DynComponent::Distance => format!("{:.4}", (dx * dx + dy * dy).sqrt()),
|
DynComponent::Distance => format!("{:.4}", (dx * dx + dy * dy).sqrt()),
|
||||||
DynComponent::Angle => format!("{:.1}", dy.atan2(dx).to_degrees().rem_euclid(360.0)),
|
DynComponent::Angle => format!("{:.1}", dy.atan2(dx).to_degrees().rem_euclid(360.0)),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue