forked from 1b-agent/opencadstudio-upstream
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:
1 parent
e31dbcb5c4
commit
5128d94b2b
5 files changed
+177
-31
No files matched your search
+4
-1
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user