forked from 1b-agent/opencadstudio-upstream
feat(dynamic-input): per-step DynSpec framework + guide overhaul
Introduce a per-command, per-step dynamic-input description so each step
shows the value boxes and guide geometry that make sense for it, instead
of a one-size-fits-all distance/angle pair.
- command: add DynSpec{anchor, fields(role+label), guide}, DynRole
(X/Y/Z/Distance/Angle/Radius/Diameter/Width/Height/Factor/Count with
label + value scaling), DynGuide(None/Polar/AxisDelta/Radius/RectSides)
and DynAnchor. New dyn_spec() trait method defaults to None so commands
on the legacy dyn_field() path are unchanged.
- overlay: redraw the dynamic-input layer by guide — polar reference line
+ angle arc, single radius line, dotted axis legs, or a cursor row —
and place each box by its role. Angle reads as the unsigned magnitude
of the short arc; a typed polar angle takes the sign of the cursor side.
- app: route fields/guide/anchor through the tab; resolve points from the
step anchor and de-scale typed values by role (typed diameter -> radius).
- circle: CR shows a radius value on one line to the cursor; CD shows the
diameter (twice the cursor radius). No angle arc on radius steps.
- pline/mline: keep polar dynamic input on vertex steps that also accept
keyword letters via point_step_accepts_keywords().
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
18f1688486
commit
b0ee1c39bd
8 files changed
+613
-150
No files matched your search
@@ -41,6 +41,9 @@ pub(super) enum DynComponent {
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct DynFieldEntry {
|
||||
pub(super) component: DynComponent,
|
||||
/// Semantic role — drives the label and value scaling (e.g. diameter).
|
||||
/// Defaults to the role matching `component` on the legacy path.
|
||||
pub(super) role: crate::command::DynRole,
|
||||
pub(super) buffer: Option<String>,
|
||||
}
|
||||
|
||||
@@ -48,6 +51,16 @@ impl DynFieldEntry {
|
||||
pub(super) fn new(component: DynComponent) -> Self {
|
||||
Self {
|
||||
component,
|
||||
role: default_role_for(component),
|
||||
buffer: None,
|
||||
}
|
||||
}
|
||||
/// Build from an explicit role (spec-driven path); the resolution
|
||||
/// component is derived from the role.
|
||||
pub(super) fn from_role(role: crate::command::DynRole) -> Self {
|
||||
Self {
|
||||
component: component_for_role(role),
|
||||
role,
|
||||
buffer: None,
|
||||
}
|
||||
}
|
||||
@@ -56,6 +69,32 @@ impl DynFieldEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a [`DynRole`](crate::command::DynRole) to the ordinate/distance/angle
|
||||
/// component used by point resolution.
|
||||
pub(super) fn component_for_role(role: crate::command::DynRole) -> DynComponent {
|
||||
use crate::command::DynRole;
|
||||
match role {
|
||||
DynRole::X | DynRole::Width => DynComponent::X,
|
||||
DynRole::Y | DynRole::Height => DynComponent::Y,
|
||||
DynRole::Z => DynComponent::Z,
|
||||
DynRole::Distance | DynRole::Radius | DynRole::Diameter => DynComponent::Distance,
|
||||
DynRole::Angle => DynComponent::Angle,
|
||||
DynRole::Factor | DynRole::Count => DynComponent::Scalar,
|
||||
}
|
||||
}
|
||||
|
||||
fn default_role_for(component: DynComponent) -> crate::command::DynRole {
|
||||
use crate::command::DynRole;
|
||||
match component {
|
||||
DynComponent::X => DynRole::X,
|
||||
DynComponent::Y => DynRole::Y,
|
||||
DynComponent::Z => DynRole::Z,
|
||||
DynComponent::Distance => DynRole::Distance,
|
||||
DynComponent::Angle => DynRole::Angle,
|
||||
DynComponent::Scalar => DynRole::Factor,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-document tab state ─────────────────────────────────────────────────
|
||||
|
||||
pub(super) struct DocumentTab {
|
||||
@@ -78,11 +117,21 @@ pub(super) struct DocumentTab {
|
||||
pub(super) visual_style: String,
|
||||
pub(super) last_cursor_world: glam::Vec3,
|
||||
pub(super) last_cursor_screen: iced::Point,
|
||||
/// Base point (`App::last_point`) projected to viewport pixels, refreshed
|
||||
/// on cursor move. Lets the dynamic-input overlay place the distance label
|
||||
/// along the rubber-band line and the angle label at its end.
|
||||
pub(super) last_point_screen: Option<iced::Point>,
|
||||
/// Dynamic-input fields shown near the cursor while a command waits
|
||||
/// for a point/distance/angle. Rebuilt whenever the active command's
|
||||
/// `dyn_field()` or the presence of a base point changes. Empty when
|
||||
/// dynamic input is not active.
|
||||
pub(super) dyn_fields: Vec<DynFieldEntry>,
|
||||
/// Guide geometry the overlay draws for the current step (set alongside
|
||||
/// `dyn_fields`). Polar arc, radius line, axis-delta projections, etc.
|
||||
pub(super) dyn_guide: crate::command::DynGuide,
|
||||
/// World-space anchor the current step's values are measured from. `None`
|
||||
/// falls back to `App::last_point`.
|
||||
pub(super) dyn_anchor: Option<glam::Vec3>,
|
||||
/// Index of the field that TAB has focused (the one keystrokes edit).
|
||||
pub(super) dyn_active: usize,
|
||||
pub(super) history: HistoryState,
|
||||
@@ -142,7 +191,10 @@ impl DocumentTab {
|
||||
visual_style: "Wireframe 2D".into(),
|
||||
last_cursor_world: glam::Vec3::ZERO,
|
||||
last_cursor_screen: iced::Point::ORIGIN,
|
||||
last_point_screen: None,
|
||||
dyn_fields: Vec::new(),
|
||||
dyn_guide: crate::command::DynGuide::Polar,
|
||||
dyn_anchor: None,
|
||||
dyn_active: 0,
|
||||
history: HistoryState::default(),
|
||||
active_layer: "0".to_string(),
|
||||
|
||||
+89
-5
@@ -2542,6 +2542,17 @@ impl OpenCADStudio {
|
||||
};
|
||||
self.tabs[i].last_cursor_world = effective;
|
||||
self.tabs[i].last_cursor_screen = p_full;
|
||||
// Project the step anchor (an explicit `dyn_anchor` or the
|
||||
// last point) so the dynamic-input overlay can place its
|
||||
// guide geometry and labels.
|
||||
let anchor = self.tabs[i].dyn_anchor.or(self.last_point);
|
||||
self.tabs[i].last_point_screen = anchor.map(|bp| {
|
||||
let ndc = view_proj.project_point3(bp);
|
||||
iced::Point::new(
|
||||
(ndc.x + 1.0) * 0.5 * bounds.width,
|
||||
(1.0 - ndc.y) * 0.5 * bounds.height,
|
||||
)
|
||||
});
|
||||
|
||||
// Entity-pick previews (TRIM/EXTEND/FILLET…) compare the
|
||||
// cursor against WCS document entities and return WCS wires.
|
||||
@@ -7464,6 +7475,13 @@ impl OpenCADStudio {
|
||||
self.tabs[i].dyn_active = 0;
|
||||
return;
|
||||
}
|
||||
// A command may describe its step explicitly via `dyn_spec()` — that
|
||||
// takes full control of the boxes, guide and anchor. Otherwise fall
|
||||
// back to the legacy `dyn_field()` shaping below.
|
||||
if let Some(spec) = self.tabs[i].active_cmd.as_ref().and_then(|c| c.dyn_spec()) {
|
||||
self.apply_dyn_spec(i, spec);
|
||||
return;
|
||||
}
|
||||
let field = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
@@ -7480,6 +7498,15 @@ impl OpenCADStudio {
|
||||
.as_ref()
|
||||
.map(|c| c.wants_text_input())
|
||||
.unwrap_or(false);
|
||||
// A point step that also accepts keyword letters (PLINE A/L/C…) keeps
|
||||
// its polar boxes: only letters reach the command line, digits stay
|
||||
// coordinates. So such a step is NOT treated as a text-only prompt.
|
||||
let point_keywords = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.point_step_accepts_keywords())
|
||||
.unwrap_or(false);
|
||||
let wants_text = wants_text && !point_keywords;
|
||||
// A step that hit-tests for an object (entity / structure pick) has no
|
||||
// coordinate to enter — clicks select, they don't place a point. Show
|
||||
// no coordinate box so the cursor stays clean and typed option keywords
|
||||
@@ -7551,6 +7578,39 @@ impl OpenCADStudio {
|
||||
self.tabs[i].dyn_fields = default.into_iter().map(DynFieldEntry::new).collect();
|
||||
self.tabs[i].dyn_active = 0;
|
||||
}
|
||||
// Derive the guide + anchor for the legacy field set so the overlay
|
||||
// draws the right construction without each command opting in.
|
||||
let comps: Vec<DynComponent> =
|
||||
self.tabs[i].dyn_fields.iter().map(|f| f.component).collect();
|
||||
self.tabs[i].dyn_guide = match comps.as_slice() {
|
||||
[DynComponent::Distance, DynComponent::Angle] | [DynComponent::Angle] => {
|
||||
crate::command::DynGuide::Polar
|
||||
}
|
||||
[DynComponent::Distance] => crate::command::DynGuide::Radius,
|
||||
_ => crate::command::DynGuide::None,
|
||||
};
|
||||
self.tabs[i].dyn_anchor = self.last_point;
|
||||
}
|
||||
|
||||
/// Apply an explicit per-step [`DynSpec`](crate::command::DynSpec): rebuild
|
||||
/// the boxes from its roles (preserving typed buffers when the role set is
|
||||
/// unchanged), and set the guide + anchor.
|
||||
fn apply_dyn_spec(&mut self, i: usize, spec: crate::command::DynSpec) {
|
||||
use super::document::DynFieldEntry;
|
||||
let new_roles: Vec<crate::command::DynRole> =
|
||||
spec.fields.iter().map(|f| f.role).collect();
|
||||
let cur_roles: Vec<crate::command::DynRole> =
|
||||
self.tabs[i].dyn_fields.iter().map(|f| f.role).collect();
|
||||
if cur_roles != new_roles {
|
||||
self.tabs[i].dyn_fields =
|
||||
spec.fields.iter().map(|f| DynFieldEntry::from_role(f.role)).collect();
|
||||
self.tabs[i].dyn_active = 0;
|
||||
}
|
||||
self.tabs[i].dyn_guide = spec.guide;
|
||||
self.tabs[i].dyn_anchor = match spec.anchor {
|
||||
crate::command::DynAnchor::LastPoint => self.last_point,
|
||||
crate::command::DynAnchor::Point(p) => Some(p),
|
||||
};
|
||||
}
|
||||
|
||||
/// Track cursor dwell over a selected entity's grip. Sets
|
||||
@@ -7718,20 +7778,39 @@ impl OpenCADStudio {
|
||||
return None;
|
||||
}
|
||||
let w = self.tabs[i].last_cursor_world;
|
||||
let base = self.last_point.unwrap_or(glam::Vec3::ZERO);
|
||||
// Buffer value parsed as f32, or the supplied live value.
|
||||
let base = self.tabs[i]
|
||||
.dyn_anchor
|
||||
.or(self.last_point)
|
||||
.unwrap_or(glam::Vec3::ZERO);
|
||||
// Buffer value parsed as f32 (de-scaled by the role so a typed diameter
|
||||
// becomes a radius), or the supplied geometric live value.
|
||||
let val = |idx: usize, live: f32| -> f32 {
|
||||
fields[idx]
|
||||
.buffer
|
||||
.as_ref()
|
||||
.map(|s| s.trim().replace(',', "."))
|
||||
.and_then(|s| crate::app::expr_eval::eval_number(&s).map(|v| v as f32))
|
||||
.map(|v| v / fields[idx].role.value_scale())
|
||||
.unwrap_or(live)
|
||||
};
|
||||
let dx = w.x - base.x;
|
||||
let dy = w.y - base.y;
|
||||
let live_d = (dx * dx + dy * dy).sqrt();
|
||||
let live_a = dy.atan2(dx); // radians
|
||||
// A typed angle is shown unsigned (0..180); give it the sign of the
|
||||
// cursor's current side so an entry made below the X axis sweeps
|
||||
// downward to match the arc instead of mirroring up. Untyped → live.
|
||||
let angle_rad = |idx: usize| -> f32 {
|
||||
match fields[idx]
|
||||
.buffer
|
||||
.as_ref()
|
||||
.map(|s| s.trim().replace(',', "."))
|
||||
.and_then(|s| crate::app::expr_eval::eval_number(&s).map(|v| v as f32))
|
||||
{
|
||||
Some(mag) => mag.abs().to_radians().copysign(dy),
|
||||
None => live_a,
|
||||
}
|
||||
};
|
||||
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
|
||||
@@ -7758,7 +7837,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
[DynComponent::Distance, DynComponent::Angle] => {
|
||||
let d = val(0, live_d);
|
||||
let a = val(1, live_a.to_degrees()).to_radians();
|
||||
let a = angle_rad(1);
|
||||
Some(glam::Vec3::new(
|
||||
base.x + d * a.cos(),
|
||||
base.y + d * a.sin(),
|
||||
@@ -7771,7 +7850,10 @@ impl OpenCADStudio {
|
||||
Some(base + dir * val(0, live_d))
|
||||
}
|
||||
[DynComponent::Angle] => {
|
||||
// Keep the cursor's distance, override the angle.
|
||||
// Standalone angle (e.g. ROTATE): the typed value is an
|
||||
// absolute CCW angle, not a cursor-signed magnitude — keep it
|
||||
// literal. Only the polar Distance+Angle pair uses the
|
||||
// cursor-signed `angle_rad`.
|
||||
let a = val(0, live_a.to_degrees()).to_radians();
|
||||
Some(glam::Vec3::new(
|
||||
base.x + live_d * a.cos(),
|
||||
@@ -7887,10 +7969,12 @@ impl OpenCADStudio {
|
||||
// `on_text_input` (a count, radius, distance) rather than resolving a
|
||||
// point. Only the typed buffer matters here — a mouse-driven live
|
||||
// value commits through the viewport click, not Enter.
|
||||
// A point-with-keywords step (PLINE) commits a typed distance/angle as
|
||||
// a point, not as text, so it is excluded here.
|
||||
let wants_text = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.wants_text_input())
|
||||
.map(|c| c.wants_text_input() && !c.point_step_accepts_keywords())
|
||||
.unwrap_or(false);
|
||||
if wants_text {
|
||||
let text = self.tabs[i]
|
||||
|
||||
+30
-15
@@ -718,6 +718,17 @@ impl OpenCADStudio {
|
||||
// perpendicular distance to a picked object); show that live
|
||||
// value in the box until the user types over it.
|
||||
let live = tab.active_cmd.as_ref().and_then(|c| c.dyn_live_value(w));
|
||||
// A Distance+Angle set is polar point entry; its angle box is
|
||||
// shown unsigned to match the overlay's short arc. A standalone
|
||||
// angle (ROTATE) keeps absolute CCW.
|
||||
let polar_pair = tab
|
||||
.dyn_fields
|
||||
.iter()
|
||||
.any(|f| f.component == DynComponent::Distance)
|
||||
&& tab
|
||||
.dyn_fields
|
||||
.iter()
|
||||
.any(|f| f.component == DynComponent::Angle);
|
||||
let boxes: Vec<overlay::DynBox> = tab
|
||||
.dyn_fields
|
||||
.iter()
|
||||
@@ -733,13 +744,14 @@ impl OpenCADStudio {
|
||||
{
|
||||
format!("{lv:.4}")
|
||||
}
|
||||
_ => dyn_component_value(f, w, base),
|
||||
_ => dyn_component_value(f, w, base, polar_pair),
|
||||
};
|
||||
overlay::DynBox {
|
||||
label: dyn_component_label(f.component),
|
||||
label: f.role.label().to_string(),
|
||||
value,
|
||||
active: idx == tab.dyn_active,
|
||||
locked: f.locked(),
|
||||
role: f.role,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -750,6 +762,8 @@ impl OpenCADStudio {
|
||||
.unwrap_or_default();
|
||||
Some(overlay::dynamic_input_overlay(
|
||||
tab.last_cursor_screen,
|
||||
tab.last_point_screen,
|
||||
tab.dyn_guide,
|
||||
boxes,
|
||||
prompt,
|
||||
))
|
||||
@@ -4050,21 +4064,15 @@ fn render_mode_picker<'a>(current: acadrust::entities::ViewportRenderMode) -> El
|
||||
// ── Dynamic-input field formatting ─────────────────────────────────────────
|
||||
|
||||
/// Short prefix shown before a dynamic-input box's value.
|
||||
fn dyn_component_label(c: DynComponent) -> String {
|
||||
match c {
|
||||
DynComponent::X => "X".into(),
|
||||
DynComponent::Y => "Y".into(),
|
||||
DynComponent::Z => "Z".into(),
|
||||
DynComponent::Distance => "d".into(),
|
||||
DynComponent::Angle => "<".into(),
|
||||
DynComponent::Scalar => "".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The string shown inside a dynamic-input box: the typed buffer when the
|
||||
/// field is locked, otherwise the live value derived from the cursor
|
||||
/// world position (and the base point for polar quantities).
|
||||
fn dyn_component_value(f: &DynFieldEntry, w: glam::Vec3, base: Option<glam::Vec3>) -> String {
|
||||
fn dyn_component_value(
|
||||
f: &DynFieldEntry,
|
||||
w: glam::Vec3,
|
||||
base: Option<glam::Vec3>,
|
||||
polar_pair: bool,
|
||||
) -> String {
|
||||
if let Some(b) = &f.buffer {
|
||||
return b.clone();
|
||||
}
|
||||
@@ -4083,7 +4091,14 @@ fn dyn_component_value(f: &DynFieldEntry, w: glam::Vec3, base: Option<glam::Vec3
|
||||
DynComponent::X => format!("{:.4}", w.x),
|
||||
DynComponent::Y => format!("{:.4}", w.y),
|
||||
DynComponent::Z => format!("{:.4}", b.z),
|
||||
DynComponent::Distance => format!("{:.4}", (dx * dx + dy * dy).sqrt()),
|
||||
// Scaled by the role so a diameter box reads twice the radius.
|
||||
DynComponent::Distance => {
|
||||
format!("{:.4}", (dx * dx + dy * dy).sqrt() * f.role.value_scale() as f64)
|
||||
}
|
||||
// Polar point entry (Distance+Angle): unsigned [0,180] to match the
|
||||
// short arc — above or below the X axis both read e.g. 30°.
|
||||
DynComponent::Angle if polar_pair => format!("{:.1}", dy.atan2(dx).to_degrees().abs()),
|
||||
// Standalone angle (ROTATE): absolute CCW 0..360, as before.
|
||||
DynComponent::Angle => format!("{:.1}", dy.atan2(dx).to_degrees().rem_euclid(360.0)),
|
||||
// Typed-only scalar — no geometric value to track when empty.
|
||||
DynComponent::Scalar => String::new(),
|
||||
|
||||
Reference in new issue
Block a user