feat(dynamic-input): ARC specs + ellipse-arc preview fixes

- 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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-15 22:03:04 +03:00
1 parent e31dbcb5c4
commit 5128d94b2b
5 files changed
+177 -31

No files matched your search

+4 -1
View File
@@ -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]
+5
View File
@@ -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,
+9
View File
@@ -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
+128 -14
View File
@@ -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<crate::command::DynSpec> {
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<f64> {
(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<WireModel> {
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<crate::command::DynSpec> {
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<f64> {
(self.step == 2).then(|| self.s.distance(cursor) as f64)
}
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
match self.step {
1 => Some(line_wire(self.s, pt)),
@@ -821,6 +878,25 @@ impl CadCommand for ArcSERCommand {
}
None
}
fn dyn_spec(&self) -> Option<crate::command::DynSpec> {
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<f64> {
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<WireModel> {
match self.step {
1 => Some(line_wire(self.s, pt)),
@@ -992,16 +1068,38 @@ impl CadCommand for ArcCSACommand {
}
None
}
fn dyn_spec(&self) -> Option<crate::command::DynSpec> {
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<f64> {
(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<WireModel> {
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<crate::command::DynSpec> {
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<f64> {
(self.step == 2).then(|| self.s.distance(cursor) as f64)
}
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
match self.step {
1 => Some(line_wire(self.c, pt)),
+31 -16
View File
@@ -19,6 +19,10 @@ fn parse_f32(text: &str) -> Option<f32> {
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)
}