forked from 1b-agent/opencadstudio-upstream
Add dynamic input for grip-based geometry editing (#1138)
This commit is contained in:
1 parent
5ffbf598a9
commit
d47e3234d8
9 files changed
+378
-39
No files matched your search
@@ -75,6 +75,10 @@ impl OpenCADStudio {
|
|||||||
.dispatch_families(cmd, i)
|
.dispatch_families(cmd, i)
|
||||||
.unwrap_or_else(Task::none);
|
.unwrap_or_else(Task::none);
|
||||||
}
|
}
|
||||||
|
// A new command owns subsequent keyboard input. Any unfinished grip
|
||||||
|
// menu value prompt must not survive and intercept that command's
|
||||||
|
// coordinates or its final empty Enter.
|
||||||
|
self.grip_pending = None;
|
||||||
// Starting a command closes any open ribbon dropdown (e.g. a style
|
// Starting a command closes any open ribbon dropdown (e.g. a style
|
||||||
// combo left open) so it does not stay stuck behind the new tool.
|
// combo left open) so it does not stay stuck behind the new tool.
|
||||||
self.ribbon.close_dropdown();
|
self.ribbon.close_dropdown();
|
||||||
|
|||||||
+123
-12
@@ -199,6 +199,16 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
|||||||
.buffer
|
.buffer
|
||||||
.get_or_insert_with(String::new)
|
.get_or_insert_with(String::new)
|
||||||
.push_str(&s);
|
.push_str(&s);
|
||||||
|
if self.tabs[i].active_grip.as_ref().is_some_and(|grip| {
|
||||||
|
matches!(
|
||||||
|
grip.mode,
|
||||||
|
GripEditMode::Lengthen
|
||||||
|
| GripEditMode::Radius
|
||||||
|
| GripEditMode::ArcLength
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
self.command_line.input.push_str(&s);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Command-line entry is shown uppercase — except in
|
// Command-line entry is shown uppercase — except in
|
||||||
// free-form text prompts, where the typed case is the
|
// free-form text prompts, where the typed case is the
|
||||||
@@ -242,6 +252,16 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
|||||||
if buf.is_empty() {
|
if buf.is_empty() {
|
||||||
self.tabs[i].dyn_fields[a].buffer = None;
|
self.tabs[i].dyn_fields[a].buffer = None;
|
||||||
}
|
}
|
||||||
|
if self.tabs[i].active_grip.as_ref().is_some_and(|grip| {
|
||||||
|
matches!(
|
||||||
|
grip.mode,
|
||||||
|
GripEditMode::Lengthen
|
||||||
|
| GripEditMode::Radius
|
||||||
|
| GripEditMode::ArcLength
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
self.command_line.input.pop();
|
||||||
|
}
|
||||||
return self.focus_cmd_input();
|
return self.focus_cmd_input();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,13 +297,49 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
|||||||
}
|
}
|
||||||
// Grip-menu value prompt — consume the typed number and
|
// Grip-menu value prompt — consume the typed number and
|
||||||
// route it through `apply_grip_menu_value`.
|
// route it through `apply_grip_menu_value`.
|
||||||
|
// Interactive grip prompts are valid only while their matching
|
||||||
|
// grip edit is alive. Do not let a stale Radius / Lengthen /
|
||||||
|
// Arc Length prompt consume Enter or coordinates from a later
|
||||||
|
// drawing command.
|
||||||
|
let stale_interactive_grip_prompt = self.grip_pending.as_ref().is_some_and(|pending| {
|
||||||
|
let expected_mode = match pending.action {
|
||||||
|
crate::scene::model::object::GripMenuAction::Lengthen => {
|
||||||
|
Some(GripEditMode::Lengthen)
|
||||||
|
}
|
||||||
|
crate::scene::model::object::GripMenuAction::Radius => {
|
||||||
|
Some(GripEditMode::Radius)
|
||||||
|
}
|
||||||
|
crate::scene::model::object::GripMenuAction::ArcLength => {
|
||||||
|
Some(GripEditMode::ArcLength)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
expected_mode.is_some_and(|mode| {
|
||||||
|
!self.tabs[self.active_tab].active_grip.as_ref().is_some_and(|grip| {
|
||||||
|
grip.mode == mode
|
||||||
|
&& grip.handle == pending.handle
|
||||||
|
&& grip.grip_id == pending.grip_id
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if stale_interactive_grip_prompt {
|
||||||
|
self.grip_pending = None;
|
||||||
|
}
|
||||||
if let Some(pending) = self.grip_pending.take() {
|
if let Some(pending) = self.grip_pending.take() {
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
if self.reject_locked_edit(i, pending.handle) {
|
if self.reject_locked_edit(i, pending.handle) {
|
||||||
self.cancel_active_grip_edit();
|
self.cancel_active_grip_edit();
|
||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
let raw = crate::app::expr_eval::eval_to_string(self.command_line.input.trim());
|
// Dynamic Input keeps numeric typing in its focused field.
|
||||||
|
// Fall back to the command-line buffer so the established
|
||||||
|
// prompt workflow remains unchanged when DYN is disabled.
|
||||||
|
let dyn_value = self.tabs[i]
|
||||||
|
.dyn_fields
|
||||||
|
.iter()
|
||||||
|
.find_map(|field| field.buffer.as_deref());
|
||||||
|
let entered = dyn_value.unwrap_or(self.command_line.input.trim());
|
||||||
|
let raw = crate::app::expr_eval::eval_to_string(entered);
|
||||||
self.command_line.input.clear();
|
self.command_line.input.clear();
|
||||||
let Ok(v) = raw.parse::<f64>() else {
|
let Ok(v) = raw.parse::<f64>() else {
|
||||||
self.command_line.push_error(crate::tf!(
|
self.command_line.push_error(crate::tf!(
|
||||||
@@ -293,15 +349,27 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
|||||||
self.grip_pending = Some(pending);
|
self.grip_pending = Some(pending);
|
||||||
return self.focus_cmd_input();
|
return self.focus_cmd_input();
|
||||||
};
|
};
|
||||||
let interactive_lengthen = self.tabs[i]
|
let interactive_value_grip = self.tabs[i]
|
||||||
.active_grip
|
.active_grip
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|grip| {
|
.is_some_and(|grip| {
|
||||||
grip.mode == GripEditMode::Lengthen
|
matches!(
|
||||||
|
(grip.mode, pending.action),
|
||||||
|
(
|
||||||
|
GripEditMode::Lengthen,
|
||||||
|
crate::scene::model::object::GripMenuAction::Lengthen,
|
||||||
|
) | (
|
||||||
|
GripEditMode::Radius,
|
||||||
|
crate::scene::model::object::GripMenuAction::Radius,
|
||||||
|
) | (
|
||||||
|
GripEditMode::ArcLength,
|
||||||
|
crate::scene::model::object::GripMenuAction::ArcLength,
|
||||||
|
)
|
||||||
|
)
|
||||||
&& grip.handle == pending.handle
|
&& grip.handle == pending.handle
|
||||||
&& grip.grip_id == pending.grip_id
|
&& grip.grip_id == pending.grip_id
|
||||||
});
|
});
|
||||||
if interactive_lengthen {
|
if interactive_value_grip {
|
||||||
self.cancel_active_grip_edit();
|
self.cancel_active_grip_edit();
|
||||||
}
|
}
|
||||||
use crate::entities::traits::EntityTypeOps;
|
use crate::entities::traits::EntityTypeOps;
|
||||||
@@ -1306,7 +1374,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
|||||||
action: item.action,
|
action: item.action,
|
||||||
label,
|
label,
|
||||||
});
|
});
|
||||||
if matches!(item.action, GripMenuAction::Lengthen) {
|
if matches!(
|
||||||
|
item.action,
|
||||||
|
GripMenuAction::Lengthen
|
||||||
|
| GripMenuAction::Radius
|
||||||
|
| GripMenuAction::ArcLength
|
||||||
|
) {
|
||||||
if let Some((_, grip)) = self.tabs[i]
|
if let Some((_, grip)) = self.tabs[i]
|
||||||
.selected_grip_handles
|
.selected_grip_handles
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1315,14 +1388,52 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
|||||||
**owner == popup.handle && grip.id == popup.grip_id
|
**owner == popup.handle && grip.id == popup.grip_id
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
self.tabs[i].active_grip = Some(GripEdit::lengthen(
|
if self.grip_originals.is_empty() {
|
||||||
popup.handle,
|
self.grip_originals = self.tabs[i]
|
||||||
popup.grip_id,
|
.scene
|
||||||
grip.world,
|
.document
|
||||||
));
|
.get_entity(popup.handle)
|
||||||
|
.cloned()
|
||||||
|
.map(|entity| vec![(popup.handle, entity)])
|
||||||
|
.unwrap_or_default();
|
||||||
|
}
|
||||||
|
self.tabs[i].active_grip = Some(match item.action {
|
||||||
|
GripMenuAction::Radius => GripEdit::radius(
|
||||||
|
popup.handle,
|
||||||
|
popup.grip_id,
|
||||||
|
grip.world,
|
||||||
|
),
|
||||||
|
GripMenuAction::ArcLength => GripEdit::arc_length(
|
||||||
|
popup.handle,
|
||||||
|
popup.grip_id,
|
||||||
|
grip.world,
|
||||||
|
),
|
||||||
|
_ => GripEdit::lengthen(
|
||||||
|
popup.handle,
|
||||||
|
popup.grip_id,
|
||||||
|
grip.world,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Popup actions do not pass through the normal grip
|
||||||
|
// press handler, which is where dynamic fields are
|
||||||
|
// usually seeded. Build the value field
|
||||||
|
// immediately so it is visible before the next mouse
|
||||||
|
// move (and so keyboard input has a field to target).
|
||||||
|
self.sync_dyn_fields();
|
||||||
|
if matches!(item.action, GripMenuAction::Radius) {
|
||||||
|
self.command_line.push_info(
|
||||||
|
crate::t!("Specify point or enter radius:").as_ref(),
|
||||||
|
);
|
||||||
|
} else if matches!(item.action, GripMenuAction::ArcLength) {
|
||||||
|
self.command_line.push_info(
|
||||||
|
crate::t!("Specify point or enter arc length:").as_ref(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
self.command_line.push_info(
|
||||||
|
crate::t!("Specify point or enter distance:").as_ref(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
self.command_line
|
|
||||||
.push_info(crate::t!("Specify point or enter distance:").as_ref());
|
|
||||||
} else {
|
} else {
|
||||||
self.command_line.push_info(crate::tf!("{label}:").as_ref());
|
self.command_line.push_info(crate::tf!("{label}:").as_ref());
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-14
@@ -65,34 +65,55 @@ impl OpenCADStudio {
|
|||||||
|
|
||||||
// A normal 2D grip stretch behaves like a point-placement step:
|
// A normal 2D grip stretch behaves like a point-placement step:
|
||||||
// distance and angle are measured from the grip's original position.
|
// distance and angle are measured from the grip's original position.
|
||||||
|
// Lengthen uses the same overlay machinery, but it only needs the
|
||||||
|
// distance field: the entity itself determines the endpoint direction
|
||||||
|
// (or arc sweep), so an angle entry would be misleading.
|
||||||
//
|
//
|
||||||
// Grip editing is not an `active_cmd`, so handle it before the normal
|
// Grip editing is not an `active_cmd`, so handle it before the normal
|
||||||
// command-only path below.
|
// command-only path below.
|
||||||
let grip_origin = self.tabs[i]
|
let grip_input = self.tabs[i]
|
||||||
.active_grip
|
.active_grip
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.filter(|grip| {
|
.map(|grip| match grip.mode {
|
||||||
grip.mode == crate::scene::pick::grip::GripEditMode::Stretch
|
crate::scene::pick::grip::GripEditMode::Stretch => {
|
||||||
})
|
(grip.origin_world, None)
|
||||||
.map(|grip| grip.origin_world);
|
}
|
||||||
|
crate::scene::pick::grip::GripEditMode::Lengthen => {
|
||||||
|
(grip.origin_world, Some(crate::command::DynRole::Distance))
|
||||||
|
}
|
||||||
|
crate::scene::pick::grip::GripEditMode::Radius => {
|
||||||
|
(grip.origin_world, Some(crate::command::DynRole::Radius))
|
||||||
|
}
|
||||||
|
crate::scene::pick::grip::GripEditMode::ArcLength => {
|
||||||
|
(grip.origin_world, Some(crate::command::DynRole::Distance))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if let Some(origin) = grip_origin {
|
if let Some((origin, scalar_role)) = grip_input {
|
||||||
let wanted = [DynComponent::Distance, DynComponent::Angle];
|
let wanted_roles: Vec<crate::command::DynRole> = scalar_role.map_or_else(
|
||||||
let current: Vec<DynComponent> = self.tabs[i]
|
|| vec![crate::command::DynRole::Distance, crate::command::DynRole::Angle],
|
||||||
|
|role| vec![role],
|
||||||
|
);
|
||||||
|
let current: Vec<crate::command::DynRole> = self.tabs[i]
|
||||||
.dyn_fields
|
.dyn_fields
|
||||||
.iter()
|
.iter()
|
||||||
.map(|field| field.component)
|
.map(|field| field.role)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if current.as_slice() != wanted {
|
if current != wanted_roles {
|
||||||
self.tabs[i].dyn_fields = wanted
|
self.tabs[i].dyn_fields = wanted_roles
|
||||||
.into_iter()
|
.iter()
|
||||||
.map(DynFieldEntry::new)
|
.copied()
|
||||||
|
.map(DynFieldEntry::from_role)
|
||||||
.collect();
|
.collect();
|
||||||
self.tabs[i].dyn_active = 0;
|
self.tabs[i].dyn_active = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.tabs[i].dyn_guide = crate::command::DynGuide::Polar;
|
self.tabs[i].dyn_guide = if scalar_role.is_some() {
|
||||||
|
crate::command::DynGuide::Radius
|
||||||
|
} else {
|
||||||
|
crate::command::DynGuide::Polar
|
||||||
|
};
|
||||||
self.tabs[i].dyn_anchor = Some(origin);
|
self.tabs[i].dyn_anchor = Some(origin);
|
||||||
self.tabs[i].dyn_ref = None;
|
self.tabs[i].dyn_ref = None;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1583,6 +1583,24 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
let live_input = s.clone();
|
let live_input = s.clone();
|
||||||
self.command_line.input = live_input.clone();
|
self.command_line.input = live_input.clone();
|
||||||
|
let i = self.active_tab;
|
||||||
|
if self.dyn_input
|
||||||
|
&& self.tabs[i].active_grip.as_ref().is_some_and(|grip| {
|
||||||
|
matches!(
|
||||||
|
grip.mode,
|
||||||
|
crate::scene::pick::grip::GripEditMode::Lengthen
|
||||||
|
| crate::scene::pick::grip::GripEditMode::Radius
|
||||||
|
| crate::scene::pick::grip::GripEditMode::ArcLength
|
||||||
|
)
|
||||||
|
})
|
||||||
|
&& !self.tabs[i].dyn_fields.is_empty()
|
||||||
|
{
|
||||||
|
let a = self.tabs[i]
|
||||||
|
.dyn_active
|
||||||
|
.min(self.tabs[i].dyn_fields.len() - 1);
|
||||||
|
self.tabs[i].dyn_fields[a].buffer =
|
||||||
|
(!live_input.is_empty()).then_some(live_input.clone());
|
||||||
|
}
|
||||||
self.command_line.autocomplete_cursor = None;
|
self.command_line.autocomplete_cursor = None;
|
||||||
self.command_line.cancel_history_navigation();
|
self.command_line.cancel_history_navigation();
|
||||||
// Live incremental search for INSERT/MINSERT: update picker on each keystroke
|
// Live incremental search for INSERT/MINSERT: update picker on each keystroke
|
||||||
|
|||||||
@@ -1580,8 +1580,19 @@ impl OpenCADStudio {
|
|||||||
|
|
||||||
let apply_started = Instant::now();
|
let apply_started = Instant::now();
|
||||||
let delta = snapped - grip.last_world;
|
let delta = snapped - grip.last_world;
|
||||||
let lengthen = grip.mode == GripEditMode::Lengthen;
|
let menu_action = match grip.mode {
|
||||||
let actions: Vec<_> = if lengthen {
|
GripEditMode::Lengthen => {
|
||||||
|
Some(crate::scene::model::object::GripMenuAction::Lengthen)
|
||||||
|
}
|
||||||
|
GripEditMode::Radius => {
|
||||||
|
Some(crate::scene::model::object::GripMenuAction::Radius)
|
||||||
|
}
|
||||||
|
GripEditMode::ArcLength => {
|
||||||
|
Some(crate::scene::model::object::GripMenuAction::ArcLength)
|
||||||
|
}
|
||||||
|
GripEditMode::Stretch => None,
|
||||||
|
};
|
||||||
|
let actions: Vec<_> = if menu_action.is_some() {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
grip.targets
|
grip.targets
|
||||||
@@ -1596,14 +1607,13 @@ impl OpenCADStudio {
|
|||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
if lengthen {
|
if let Some(action) = menu_action {
|
||||||
let original = self
|
let original = self
|
||||||
.grip_originals
|
.grip_originals
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(handle, _)| *handle == grip.handle)
|
.find(|(handle, _)| *handle == grip.handle)
|
||||||
.map(|(_, entity)| entity.clone());
|
.map(|(_, entity)| entity.clone());
|
||||||
if let Some(original) = original {
|
if let Some(original) = original {
|
||||||
let action = crate::scene::model::object::GripMenuAction::Lengthen;
|
|
||||||
let value = crate::scene::view::dispatch::grip_menu_point_value(
|
let value = crate::scene::view::dispatch::grip_menu_point_value(
|
||||||
&original,
|
&original,
|
||||||
grip.grip_id,
|
grip.grip_id,
|
||||||
@@ -3232,7 +3242,10 @@ impl OpenCADStudio {
|
|||||||
// Engaging click — stay hot, wait for the placement click.
|
// Engaging click — stay hot, wait for the placement click.
|
||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
if grip.mode == GripEditMode::Lengthen {
|
if matches!(
|
||||||
|
grip.mode,
|
||||||
|
GripEditMode::Lengthen | GripEditMode::Radius | GripEditMode::ArcLength
|
||||||
|
) {
|
||||||
self.grip_pending = None;
|
self.grip_pending = None;
|
||||||
self.command_line.input.clear();
|
self.command_line.input.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-5
@@ -927,7 +927,32 @@ bg={bg_ms:.1}ms n={view_count}"
|
|||||||
// A command may drive a typed scalar by mouse (e.g. a
|
// A command may drive a typed scalar by mouse (e.g. a
|
||||||
// perpendicular distance to a picked object); show that live
|
// perpendicular distance to a picked object); show that live
|
||||||
// value in the box until the user types over it.
|
// 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));
|
let live = tab.active_cmd.as_ref().and_then(|c| c.dyn_live_value(w)).or_else(|| {
|
||||||
|
let grip = tab.active_grip.as_ref()?;
|
||||||
|
let action = match grip.mode {
|
||||||
|
crate::scene::pick::grip::GripEditMode::Lengthen => {
|
||||||
|
crate::scene::model::object::GripMenuAction::Lengthen
|
||||||
|
}
|
||||||
|
crate::scene::pick::grip::GripEditMode::Radius => {
|
||||||
|
crate::scene::model::object::GripMenuAction::Radius
|
||||||
|
}
|
||||||
|
crate::scene::pick::grip::GripEditMode::ArcLength => {
|
||||||
|
crate::scene::model::object::GripMenuAction::ArcLength
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let original = self
|
||||||
|
.grip_originals
|
||||||
|
.iter()
|
||||||
|
.find(|(handle, _)| *handle == grip.handle)
|
||||||
|
.map(|(_, entity)| entity)?;
|
||||||
|
crate::scene::view::dispatch::grip_menu_point_value(
|
||||||
|
original,
|
||||||
|
grip.grip_id,
|
||||||
|
action,
|
||||||
|
w,
|
||||||
|
)
|
||||||
|
});
|
||||||
let boxes: Vec<crate::ui::overlay::DynBox> = tab
|
let boxes: Vec<crate::ui::overlay::DynBox> = tab
|
||||||
.dyn_fields
|
.dyn_fields
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1687,17 +1712,28 @@ bg={bg_ms:.1}ms n={view_count}"
|
|||||||
// viewport stack rather than as a separate row in the main
|
// viewport stack rather than as a separate row in the main
|
||||||
// column — frees up vertical space when no command is active
|
// column — frees up vertical space when no command is active
|
||||||
// and keeps the input close to where the cursor is drawing.
|
// and keeps the input close to where the cursor is drawing.
|
||||||
// Autocomplete shows only when no command is collecting its
|
// Autocomplete shows only when no command or grip-menu action is
|
||||||
// own input (otherwise typed prefixes are coordinates / values).
|
// collecting its own input. A pending Lengthen / Radius / Arc Length
|
||||||
let allow_autocomplete = tab.active_cmd.is_none();
|
// value is numeric input, not the prefix of a new command (for example,
|
||||||
|
// `3` must not open the 3DALIGN / 3DARRAY suggestions).
|
||||||
|
let allow_autocomplete = tab.active_cmd.is_none() && self.grip_pending.is_none();
|
||||||
// Dynamic input captures keystrokes when its fields are showing,
|
// Dynamic input captures keystrokes when its fields are showing,
|
||||||
// so the command-line field must release focus / its on_input.
|
// so the command-line field must release focus / its on_input.
|
||||||
// The MText preview also captures keystrokes (typing edits it), so the
|
// The MText preview also captures keystrokes (typing edits it), so the
|
||||||
// command line must likewise release its on_input there.
|
// command line must likewise release its on_input there.
|
||||||
|
let interactive_value_grip = tab.active_grip.as_ref().is_some_and(|grip| {
|
||||||
|
matches!(
|
||||||
|
grip.mode,
|
||||||
|
crate::scene::pick::grip::GripEditMode::Lengthen
|
||||||
|
| crate::scene::pick::grip::GripEditMode::Radius
|
||||||
|
| crate::scene::pick::grip::GripEditMode::ArcLength
|
||||||
|
)
|
||||||
|
});
|
||||||
let dyn_capturing =
|
let dyn_capturing =
|
||||||
(self.dyn_input
|
(self.dyn_input
|
||||||
&& (tab.active_cmd.is_some() || tab.active_grip.is_some())
|
&& (tab.active_cmd.is_some() || tab.active_grip.is_some())
|
||||||
&& !tab.dyn_fields.is_empty())
|
&& !tab.dyn_fields.is_empty()
|
||||||
|
&& !interactive_value_grip)
|
||||||
|| self.mtext_editor.as_ref().is_some_and(|e| e.show_preview)
|
|| self.mtext_editor.as_ref().is_some_and(|e| e.show_preview)
|
||||||
|| self.text_inline.is_some();
|
|| self.text_inline.is_some();
|
||||||
// The workspace row is: left edge stack, viewport, right edge stack.
|
// The workspace row is: left edge stack, viewport, right edge stack.
|
||||||
|
|||||||
+20
-1
@@ -365,13 +365,32 @@ impl crate::entities::traits::Grippable for Arc {
|
|||||||
point: glam::DVec3,
|
point: glam::DVec3,
|
||||||
) -> Option<f64> {
|
) -> Option<f64> {
|
||||||
use crate::scene::model::object::GripMenuAction as A;
|
use crate::scene::model::object::GripMenuAction as A;
|
||||||
if !matches!(action, A::Lengthen) || self.radius <= 1.0e-9 {
|
if self.radius <= 1.0e-9 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let (x, y, _) = crate::scene::view::transform::wcs_point_to_ocs(
|
let (x, y, _) = crate::scene::view::transform::wcs_point_to_ocs(
|
||||||
(point.x, point.y, point.z),
|
(point.x, point.y, point.z),
|
||||||
(self.normal.x, self.normal.y, self.normal.z),
|
(self.normal.x, self.normal.y, self.normal.z),
|
||||||
);
|
);
|
||||||
|
if matches!(action, A::Radius) && grip_id == 3 {
|
||||||
|
let radius = ((x - self.center.x).powi(2) + (y - self.center.y).powi(2)).sqrt();
|
||||||
|
return (radius > 1.0e-9).then_some(radius);
|
||||||
|
}
|
||||||
|
if matches!(action, A::ArcLength) && grip_id == 3 {
|
||||||
|
let sweep = (self.end_angle - self.start_angle).rem_euclid(TAU);
|
||||||
|
let middle_angle = self.start_angle + sweep * 0.5;
|
||||||
|
let middle_x = self.center.x + self.radius * middle_angle.cos();
|
||||||
|
let middle_y = self.center.y + self.radius * middle_angle.sin();
|
||||||
|
let tangent_x = -middle_angle.sin();
|
||||||
|
let tangent_y = middle_angle.cos();
|
||||||
|
let delta = (x - middle_x) * tangent_x + (y - middle_y) * tangent_y;
|
||||||
|
let length = self.radius * sweep + delta;
|
||||||
|
return (length > 1.0e-9 && length < self.radius * TAU - 1.0e-9)
|
||||||
|
.then_some(length);
|
||||||
|
}
|
||||||
|
if !matches!(action, A::Lengthen) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let cursor_angle = (y - self.center.y).atan2(x - self.center.x);
|
let cursor_angle = (y - self.center.y).atan2(x - self.center.x);
|
||||||
let current_sweep = (self.end_angle - self.start_angle).rem_euclid(TAU);
|
let current_sweep = (self.end_angle - self.start_angle).rem_euclid(TAU);
|
||||||
let desired_sweep = match grip_id {
|
let desired_sweep = match grip_id {
|
||||||
|
|||||||
+105
-2
@@ -256,7 +256,89 @@ impl RenderConvertible for Circle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
crate::impl_entity_basics!(Circle);
|
impl crate::entities::traits::Grippable for Circle {
|
||||||
|
fn grips(&self) -> Vec<GripDef> {
|
||||||
|
grips(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_grip(&mut self, grip_id: usize, apply: GripApply) {
|
||||||
|
apply_grip(self, grip_id, apply);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grip_menu(
|
||||||
|
&self,
|
||||||
|
grip_id: usize,
|
||||||
|
) -> Vec<crate::scene::model::object::GripMenuItem> {
|
||||||
|
use crate::scene::model::object::{GripMenuAction, GripMenuItem};
|
||||||
|
let mut items = vec![GripMenuItem {
|
||||||
|
label: "Stretch",
|
||||||
|
action: GripMenuAction::Stretch,
|
||||||
|
}];
|
||||||
|
if (1..=4).contains(&grip_id) {
|
||||||
|
items.push(GripMenuItem {
|
||||||
|
label: "Radius",
|
||||||
|
action: GripMenuAction::Radius,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grip_menu_value_prompt(
|
||||||
|
&self,
|
||||||
|
_grip_id: usize,
|
||||||
|
action: crate::scene::model::object::GripMenuAction,
|
||||||
|
) -> Option<&'static str> {
|
||||||
|
matches!(action, crate::scene::model::object::GripMenuAction::Radius)
|
||||||
|
.then_some("New radius")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grip_menu_point_value(
|
||||||
|
&self,
|
||||||
|
grip_id: usize,
|
||||||
|
action: crate::scene::model::object::GripMenuAction,
|
||||||
|
point: glam::DVec3,
|
||||||
|
) -> Option<f64> {
|
||||||
|
if !matches!(action, crate::scene::model::object::GripMenuAction::Radius)
|
||||||
|
|| !(1..=4).contains(&grip_id)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let plane = crate::entities::curve::circle_curve(self).plane;
|
||||||
|
let point = plane.project(point.to_array())?;
|
||||||
|
let radius = (point[0] - self.center.x).hypot(point[1] - self.center.y);
|
||||||
|
(radius > 1.0e-9).then_some(radius)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_grip_menu_value(
|
||||||
|
&mut self,
|
||||||
|
grip_id: usize,
|
||||||
|
action: crate::scene::model::object::GripMenuAction,
|
||||||
|
value: f64,
|
||||||
|
) {
|
||||||
|
if matches!(action, crate::scene::model::object::GripMenuAction::Radius)
|
||||||
|
&& (1..=4).contains(&grip_id)
|
||||||
|
&& value > 1.0e-9
|
||||||
|
{
|
||||||
|
self.radius = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::entities::traits::PropertyEditable for Circle {
|
||||||
|
fn geometry_properties(&self, _text_style_names: &[String]) -> Vec<PropSection> {
|
||||||
|
properties(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_geom_prop(&mut self, field: &str, value: &str) {
|
||||||
|
apply_geom_prop(self, field, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::entities::traits::Transformable for Circle {
|
||||||
|
fn apply_transform(&mut self, transform: &EntityTransform) {
|
||||||
|
apply_transform(self, transform);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl crate::entities::traits::MassPropsCalc for Circle {
|
impl crate::entities::traits::MassPropsCalc for Circle {
|
||||||
fn mass_props(&self) -> crate::entities::traits::MassProps {
|
fn mass_props(&self) -> crate::entities::traits::MassProps {
|
||||||
@@ -276,6 +358,28 @@ impl crate::entities::traits::MassPropsCalc for Circle {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quadrant_grips_offer_interactive_radius() {
|
||||||
|
use crate::entities::traits::Grippable;
|
||||||
|
use crate::scene::model::object::GripMenuAction;
|
||||||
|
|
||||||
|
let mut circle = Circle::default();
|
||||||
|
circle.radius = 2.0;
|
||||||
|
|
||||||
|
assert_eq!(circle.grip_menu(0).len(), 1);
|
||||||
|
assert!(circle
|
||||||
|
.grip_menu(1)
|
||||||
|
.iter()
|
||||||
|
.any(|item| item.action == GripMenuAction::Radius));
|
||||||
|
assert_eq!(
|
||||||
|
circle.grip_menu_value_prompt(1, GripMenuAction::Radius),
|
||||||
|
Some("New radius")
|
||||||
|
);
|
||||||
|
|
||||||
|
circle.apply_grip_menu_value(1, GripMenuAction::Radius, 5.0);
|
||||||
|
assert_eq!(circle.radius, 5.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_circle_segments_scales_with_zoom() {
|
fn test_circle_segments_scales_with_zoom() {
|
||||||
let r = 10.0;
|
let r = 10.0;
|
||||||
@@ -361,4 +465,3 @@ mod tests {
|
|||||||
assert!(close_count > far_count, "{close_count} <= {far_count}");
|
assert!(close_count > far_count, "{close_count} <= {far_count}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +54,8 @@ pub struct GripEdit {
|
|||||||
pub enum GripEditMode {
|
pub enum GripEditMode {
|
||||||
Stretch,
|
Stretch,
|
||||||
Lengthen,
|
Lengthen,
|
||||||
|
Radius,
|
||||||
|
ArcLength,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -92,6 +94,18 @@ impl GripEdit {
|
|||||||
edit.mode = GripEditMode::Lengthen;
|
edit.mode = GripEditMode::Lengthen;
|
||||||
edit
|
edit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn radius(handle: Handle, grip_id: usize, world: DVec3) -> Self {
|
||||||
|
let mut edit = Self::single(handle, grip_id, false, world);
|
||||||
|
edit.mode = GripEditMode::Radius;
|
||||||
|
edit
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn arc_length(handle: Handle, grip_id: usize, world: DVec3) -> Self {
|
||||||
|
let mut edit = Self::single(handle, grip_id, false, world);
|
||||||
|
edit.mode = GripEditMode::ArcLength;
|
||||||
|
edit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Screen-space helpers ───────────────────────────────────────────────────
|
// ── Screen-space helpers ───────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in new issue
Block a user