From 5128d94b2b918db4616dc443cad01c02fedf3acb Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 15 Jun 2026 22:03:04 +0300 Subject: [PATCH] feat(dynamic-input): ARC specs + ellipse-arc preview fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ARC: per-step DynSpec for the typed-value modes — included-angle (SCA, CSA), chord length (SCL, CSL) and radius (SER) show their box and route the typed value through on_text_input via a new dyn_commit_as_text() hook; the angle box previews the live span. Point/3P/SCE steps stay on the legacy polar path. - ARC + ellipse-arc: derive the previewed sweep direction from the swept angle with a ~10° tolerance and advance the reference only on a clear move, so small cursor jitter no longer flips the arc. Ellipse uses the parametric angle so a flat ellipse isn't sticky. - Ellipse arc: the start-angle step now shows a line from the centre to the cursor (it only picks the start angle) instead of a misleading arc. - Ellipse arc: use the same minor-axis direction (Z x major) as the rendered entity so the committed arc matches the preview instead of mirroring across the major axis. Co-Authored-By: Claude Opus 4.8 --- src/app/update.rs | 5 +- src/app/view.rs | 5 ++ src/command/mod.rs | 9 ++ src/modules/home/draw/arc.rs | 142 ++++++++++++++++++++++++++++--- src/modules/home/draw/ellipse.rs | 47 ++++++---- 5 files changed, 177 insertions(+), 31 deletions(-) diff --git a/src/app/update.rs b/src/app/update.rs index 371b9db21..8c90452dc 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -8014,7 +8014,10 @@ impl OpenCADStudio { let wants_text = self.tabs[i] .active_cmd .as_ref() - .map(|c| c.wants_text_input() && !c.point_step_accepts_keywords()) + .map(|c| { + (c.wants_text_input() && !c.point_step_accepts_keywords()) + || c.dyn_commit_as_text() + }) .unwrap_or(false); if wants_text { let text = self.tabs[i] diff --git a/src/app/view.rs b/src/app/view.rs index b9905727f..c3ab020f2 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -736,6 +736,11 @@ impl OpenCADStudio { .map(|(idx, f)| { let value = match (&f.buffer, live) { (Some(b), _) => b.clone(), + // An angle step with a command-supplied live value + // (ARC span / direction) shows it in degrees. + (None, Some(lv)) if f.component == DynComponent::Angle => { + format!("{lv:.1}") + } (None, Some(lv)) if matches!( f.component, diff --git a/src/command/mod.rs b/src/command/mod.rs index b432834e6..bd0621f9f 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -567,6 +567,15 @@ pub trait CadCommand: Send { DynField::Point } + /// When true, a value typed into the dynamic-input box for this step is + /// committed via `on_text_input` (as a string the command parses) rather + /// than resolved into a point. Used by steps whose typed value is a span / + /// included angle / length the command interprets itself (e.g. ARC angle + /// modes), while the box still previews a live value from the cursor. + fn dyn_commit_as_text(&self) -> bool { + false + } + /// Explicit per-step dynamic-input description. `Some(spec)` takes full /// control of the boxes, guide geometry and anchor for this step; `None` /// (the default) falls back to the legacy `dyn_field()` behaviour so diff --git a/src/modules/home/draw/arc.rs b/src/modules/home/draw/arc.rs index 361db1924..8b8be8b48 100644 --- a/src/modules/home/draw/arc.rs +++ b/src/modules/home/draw/arc.rs @@ -104,12 +104,17 @@ fn make_arc(center: Vec3, radius: f32, start_angle: f32, end_angle: f32) -> Enti /// Signed rotation from `prev` to `curr` around `center`. /// Positive = CCW, negative = CW. -fn rot_sign(center: Vec3, prev: Vec3, curr: Vec3) -> f32 { +/// Signed angle (radians) swept from `prev` to `curr` about `center`. +fn rot_delta(center: Vec3, prev: Vec3, curr: Vec3) -> f32 { let p = prev - center; let c = curr - center; - p.x * c.y - p.y * c.x + (p.x * c.y - p.y * c.x).atan2(p.x * c.x + p.y * c.y) } +/// Minimum swept angle before the previewed arc may flip CW/CCW. Filters the +/// per-frame cursor jitter that otherwise reverses the sweep on tiny moves. +const DIR_TOL: f32 = 0.1745; // ~10° + fn line_wire(a: Vec3, b: Vec3) -> WireModel { WireModel::solid( "rubber_band".into(), @@ -284,11 +289,17 @@ impl CadCommand for ArcCommand { 1 => Some(line_wire(self.c, pt)), 2 => { if let Some(prev) = self.prev_pt { - if rot_sign(self.c, prev, pt).abs() > 1e-6 { - self.cw = rot_sign(self.c, prev, pt) < 0.0; + // Only flip the sweep once the cursor has moved a clear + // angular step; keep the reference point until then so slow + // moves accumulate and jitter is ignored. + let d = rot_delta(self.c, prev, pt); + if d.abs() > DIR_TOL { + self.cw = d < 0.0; + self.prev_pt = Some(pt); } + } else { + self.prev_pt = Some(pt); } - self.prev_pt = Some(pt); let ea = angle_xy(self.c, pt); Some(if self.cw { arc_preview(self.c, self.r, ea, self.sa) @@ -457,11 +468,17 @@ impl CadCommand for ArcSCECommand { 1 => Some(line_wire(self.s, pt)), 2 => { if let Some(prev) = self.prev_pt { - if rot_sign(self.c, prev, pt).abs() > 1e-6 { - self.cw = rot_sign(self.c, prev, pt) < 0.0; + // Only flip the sweep once the cursor has moved a clear + // angular step; keep the reference point until then so slow + // moves accumulate and jitter is ignored. + let d = rot_delta(self.c, prev, pt); + if d.abs() > DIR_TOL { + self.cw = d < 0.0; + self.prev_pt = Some(pt); } + } else { + self.prev_pt = Some(pt); } - self.prev_pt = Some(pt); let ea = angle_xy(self.c, pt); Some(if self.cw { arc_preview(self.c, self.r, ea, self.sa) @@ -557,16 +574,40 @@ impl CadCommand for ArcSCACommand { } None } + fn dyn_spec(&self) -> Option { + use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec}; + // Included angle (span) at the centre. Typed value is the span, handled + // by on_text_input; the box previews the live span via dyn_live_value. + (self.step == 2).then(|| DynSpec { + anchor: DynAnchor::Point(self.c), + fields: vec![DynFieldSpec::new(DynRole::Angle)], + guide: DynGuide::Polar, + ref_point: None, + }) + } + fn dyn_commit_as_text(&self) -> bool { + self.step == 2 + } + 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) + } fn on_mouse_move(&mut self, pt: Vec3) -> Option { match self.step { 1 => Some(line_wire(self.s, pt)), 2 => { if let Some(prev) = self.prev_pt { - if rot_sign(self.c, prev, pt).abs() > 1e-6 { - self.cw = rot_sign(self.c, prev, pt) < 0.0; + // Only flip the sweep once the cursor has moved a clear + // angular step; keep the reference point until then so slow + // moves accumulate and jitter is ignored. + let d = rot_delta(self.c, prev, pt); + if d.abs() > DIR_TOL { + self.cw = d < 0.0; + self.prev_pt = Some(pt); } + } else { + self.prev_pt = Some(pt); } - self.prev_pt = Some(pt); let ea = angle_xy(self.c, pt); Some(if self.cw { arc_preview(self.c, self.r, ea, self.sa) @@ -656,6 +697,22 @@ impl CadCommand for ArcSCLCommand { } None } + fn dyn_spec(&self) -> Option { + use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec}; + // Chord length from the start point (typed → on_text_input). + (self.step == 2).then(|| DynSpec { + anchor: DynAnchor::Point(self.s), + fields: vec![DynFieldSpec::new(DynRole::Distance)], + guide: DynGuide::Radius, + ref_point: None, + }) + } + fn dyn_commit_as_text(&self) -> bool { + self.step == 2 + } + fn dyn_live_value(&self, cursor: Vec3) -> Option { + (self.step == 2).then(|| self.s.distance(cursor) as f64) + } fn on_mouse_move(&mut self, pt: Vec3) -> Option { match self.step { 1 => Some(line_wire(self.s, pt)), @@ -821,6 +878,25 @@ impl CadCommand for ArcSERCommand { } None } + fn dyn_spec(&self) -> Option { + use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec}; + // Radius value (typed → on_text_input); the preview arc is the guide. + (self.step == 2).then(|| DynSpec { + anchor: DynAnchor::Point(self.s), + fields: vec![DynFieldSpec::new(DynRole::Radius)], + guide: DynGuide::None, + ref_point: None, + }) + } + fn dyn_commit_as_text(&self) -> bool { + self.step == 2 + } + fn dyn_live_value(&self, cursor: Vec3) -> Option { + if self.step != 2 { + return None; + } + arc_from_se_radius(self.s, self.e, cursor).map(|(_, r)| r as f64) + } fn on_mouse_move(&mut self, pt: Vec3) -> Option { match self.step { 1 => Some(line_wire(self.s, pt)), @@ -992,16 +1068,38 @@ impl CadCommand for ArcCSACommand { } None } + fn dyn_spec(&self) -> Option { + use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec}; + (self.step == 2).then(|| DynSpec { + anchor: DynAnchor::Point(self.c), + fields: vec![DynFieldSpec::new(DynRole::Angle)], + guide: DynGuide::Polar, + ref_point: None, + }) + } + fn dyn_commit_as_text(&self) -> bool { + self.step == 2 + } + 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) + } fn on_mouse_move(&mut self, pt: Vec3) -> Option { match self.step { 1 => Some(line_wire(self.c, pt)), 2 => { if let Some(prev) = self.prev_pt { - if rot_sign(self.c, prev, pt).abs() > 1e-6 { - self.cw = rot_sign(self.c, prev, pt) < 0.0; + // Only flip the sweep once the cursor has moved a clear + // angular step; keep the reference point until then so slow + // moves accumulate and jitter is ignored. + let d = rot_delta(self.c, prev, pt); + if d.abs() > DIR_TOL { + self.cw = d < 0.0; + self.prev_pt = Some(pt); } + } else { + self.prev_pt = Some(pt); } - self.prev_pt = Some(pt); let ea = angle_xy(self.c, pt); Some(if self.cw { arc_preview(self.c, self.r, ea, self.sa) @@ -1090,6 +1188,22 @@ impl CadCommand for ArcCSLCommand { } None } + fn dyn_spec(&self) -> Option { + use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec}; + // Chord length from the start point (typed → on_text_input). + (self.step == 2).then(|| DynSpec { + anchor: DynAnchor::Point(self.s), + fields: vec![DynFieldSpec::new(DynRole::Distance)], + guide: DynGuide::Radius, + ref_point: None, + }) + } + fn dyn_commit_as_text(&self) -> bool { + self.step == 2 + } + fn dyn_live_value(&self, cursor: Vec3) -> Option { + (self.step == 2).then(|| self.s.distance(cursor) as f64) + } fn on_mouse_move(&mut self, pt: Vec3) -> Option { match self.step { 1 => Some(line_wire(self.c, pt)), diff --git a/src/modules/home/draw/ellipse.rs b/src/modules/home/draw/ellipse.rs index 76520b300..8350e1b24 100644 --- a/src/modules/home/draw/ellipse.rs +++ b/src/modules/home/draw/ellipse.rs @@ -19,6 +19,10 @@ fn parse_f32(text: &str) -> Option { const TAU: f32 = std::f32::consts::TAU; +/// Minimum swept angle before the previewed arc may flip CW/CCW — filters the +/// per-frame cursor jitter that otherwise reverses the sweep on tiny moves. +const DIR_TOL: f32 = 0.1745; // ~10° + // ── Icons ───────────────────────────────────────────────────────────────── const ICON_CTR: IconKind = IconKind::Svg(include_bytes!( @@ -58,7 +62,7 @@ fn ellipse_wire( return WireModel::solid("rubber_band".into(), vec![], WireModel::CYAN, false); } let major_dir = major / r_major; - let v = major_dir.cross(Vec3::Z).normalize(); + let v = Vec3::Z.cross(major_dir).normalize(); let segs = 64u32; // Unwrap t_end so the arc goes counter-clockwise. let t_e = if t_end <= t_start { t_end + TAU } else { t_end }; @@ -502,13 +506,11 @@ impl CadCommand for EllipseArcCommand { let ratio = minor_ratio(*center, *major, pt).max(0.001); Some(ellipse_wire(*center, *major, ratio, 0.0, TAU)) } - ArcStep::StartAngle { - center, - major, - ratio, - } => { - let t = angle_from_point(*center, *major, *ratio, pt); - Some(ellipse_wire(*center, *major, *ratio, 0.0, t)) + ArcStep::StartAngle { center, .. } => { + // Only the start angle is being chosen here — show a line from + // the centre to the cursor to indicate that angle, not a + // (misleading) full arc preview. + Some(line_wire(*center, pt)) } ArcStep::EndAngle { center, @@ -516,16 +518,29 @@ impl CadCommand for EllipseArcCommand { ratio, t_start, } => { - // Detect rotation direction via cross product. + // Detect sweep direction from the change in PARAMETRIC angle + // (the visual sweep along the ellipse), not the geometric angle + // about the centre — on a flat ellipse a large visible move can + // be a tiny centre angle, which made the direction stick. A + // tolerance ignores jitter; the reference advances only on a + // clear move so slow sweeps accumulate. if let Some(prev) = self.prev_pt { - let p = prev - *center; - let c = pt - *center; - let sign = p.x * c.y - p.y * c.x; - if sign.abs() > 1e-6 { - self.cw = sign < 0.0; + let t_prev = angle_from_point(*center, *major, *ratio, prev); + let t_cur = angle_from_point(*center, *major, *ratio, pt); + let mut d = t_cur - t_prev; + while d > std::f32::consts::PI { + d -= TAU; } + while d <= -std::f32::consts::PI { + d += TAU; + } + if d.abs() > DIR_TOL { + self.cw = d < 0.0; + self.prev_pt = Some(pt); + } + } else { + self.prev_pt = Some(pt); } - self.prev_pt = Some(pt); let t_end = angle_from_point(*center, *major, *ratio, pt); Some(if self.cw { ellipse_wire(*center, *major, *ratio, t_end, *t_start) @@ -590,7 +605,7 @@ fn angle_from_point(center: Vec3, major: Vec3, ratio: f32, pt: Vec3) -> f32 { return 0.0; } let major_dir = major / r_major; - let v = major_dir.cross(Vec3::Z).normalize(); + let v = Vec3::Z.cross(major_dir).normalize(); param_angle(center, major_dir, v, pt, ratio) }