Files
OneCAD-Draft/src/app/command_driver/utilities.rs
T
ramox81andClaude Opus 5.5 b9fd0a066c Selection: count duplicates and removals from the step's own picks
The count line now uses the objects the selection step itself picked
(a click, a window or crossing, a lasso, a fence, Last / Previous /
All): "N found (D duplicate), M total" when some were chosen already,
"N found, R removed, M total" in remove mode, "N found, M total" when a
removal picks objects that were not chosen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 14:49:27 +03:00

1330 lines
56 KiB
Rust

use super::*;
impl OpenCADStudio {
pub(in crate::app) fn reject_locked_edit(&mut self, i: usize, handle: Handle) -> bool {
let Some(layer) = self.tabs[i].scene.locked_layer_name(handle) else {
return false;
};
self.command_line.push_info(
crate::tf!("Object is on locked layer \"{layer}\" — unlock the layer to edit it.")
.as_ref(),
);
true
}
pub(in crate::app) fn refresh_area_preview(&mut self, i: usize) {
let hatches = self.tabs[i]
.active_cmd
.as_ref()
.and_then(|command| command.hatch_preview_models());
if let Some(hatches) = hatches {
self.tabs[i].scene.set_command_preview_hatches(hatches);
return;
}
let regions = self.tabs[i]
.active_cmd
.as_ref()
.and_then(|command| command.area_preview_regions());
if let Some(regions) = regions {
self.tabs[i].scene.set_area_preview_regions(&regions);
}
}
/// Point supplied by a bare Enter before LINE/PLINE's first click. Prefer
/// the current endpoint of the most recently created path drawable in the
/// active space. A loaded drawing has no runtime anchor, so recover its
/// newest line/arc/polyline endpoint. An empty space starts at the active
/// UCS origin (0,0,0 in user coordinates).
fn default_draw_start(&self, i: usize) -> glam::DVec3 {
let tab = &self.tabs[i];
let endpoint = |entity: &codec::EntityType| {
let last_grip = match entity {
codec::EntityType::Line(line) => {
return Some(glam::DVec3::new(line.end.x, line.end.y, line.end.z));
}
codec::EntityType::Arc(_) => Some(2),
codec::EntityType::LwPolyline(polyline) => polyline.vertices.len().checked_sub(1),
codec::EntityType::Polyline(polyline) => polyline.vertices.len().checked_sub(1),
codec::EntityType::Polyline2D(polyline) => polyline.vertices.len().checked_sub(1),
codec::EntityType::Polyline3D(polyline) => polyline.vertices.len().checked_sub(1),
_ => None,
}?;
crate::scene::view::dispatch::grips(entity)
.into_iter()
.find(|grip| grip.id == last_grip)
.map(|grip| grip.world)
};
if let Some(handle) = tab.last_draw_anchor {
if tab.scene.entity_belongs_to_active_space(handle) {
if let Some(point) = tab.scene.document.get_entity(handle).and_then(endpoint) {
return point;
}
}
}
let recovered = tab
.scene
.document
.entities()
.filter_map(|entity| {
let handle = entity.common().handle;
tab.scene
.entity_belongs_to_active_space(handle)
.then(|| endpoint(entity).map(|point| (handle, point)))
.flatten()
})
.max_by_key(|(handle, _)| handle.value())
.map(|(_, point)| point);
recovered.unwrap_or_else(|| tab.ucs_origin_world())
}
/// Drop cursor-relative state that was computed in the drawing space being
/// left. This is also used by MVIEW, whose command object survives its
/// intentional paper/model round-trip while its old-space overlays cannot.
pub(in crate::app) fn reset_space_interaction_state(&mut self) {
let i = self.active_tab;
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].snap_result = None;
self.last_point = None;
// Points collected in the space being left are meaningless in the new
// one.
self.clear_accepted_snaps();
self.pending_click_snap = None;
self.snapper.from_point = None;
self.snapper.clear_tracking();
self.otrack_active = None;
self.otrack_cross = None;
self.otrack_kind = None;
self.axis_lock_dir = None;
self.angle_override = None;
self.dyn_user_reshaped = false;
self.dyn_coord_absolute = false;
self.grip_hover = None;
self.grip_popup = None;
self.grip_pending = None;
self.visibility_popup = None;
self.hover_dwell = None;
self.ucs_grip_drag = None;
self.ucs_icon_selected = false;
self.ucs_icon_hover = false;
self.tabs[i].pan_mode = false;
self.tabs[i].orbit_mode = false;
self.tabs[i].zoom_dynamic_mode = false;
let _ = self.on_viewport_exit();
}
pub(in crate::app) fn solve_grip_constraints(
&mut self,
i: usize,
grip: &crate::scene::pick::grip::GripEdit,
) {
let touched: Vec<_> = grip.targets.iter().map(|target| target.handle).collect();
let connected = self.tabs[i].scene.parametric_connected_handles(
self.tabs[i].current_parametric_scope(),
&touched,
true,
);
for handle in connected {
if !self
.grip_originals
.iter()
.any(|(original, _)| *original == handle)
{
if let Some(entity) = self.tabs[i].scene.document.get_entity(handle).cloned() {
self.grip_originals.push((handle, entity));
}
}
if !self.grip_preview_handles.contains(&handle) {
self.grip_preview_handles.push(handle);
if !self.tabs[i].scene.meshes.contains_key(&handle) {
self.tabs[i].scene.preview_hidden.insert(handle);
}
}
}
let driven_refs: Vec<_> = grip
.targets
.iter()
.flat_map(|target| {
self.tabs[i]
.scene
.document
.get_entity(target.handle)
.map(|entity| {
crate::scene::parametric_constraints::grip_solve_anchor_refs(
entity,
target.handle,
target.grip_id,
)
})
.unwrap_or_default()
})
.collect();
let retain_size = self.constraint_solve_mode
&& !driven_refs.is_empty()
&& driven_refs
.iter()
.all(|reference| reference.marker.is_some());
let solved = self.tabs[i].scene.solve_parametric_constraints_preview(
&touched,
&driven_refs,
retain_size,
&self.grip_originals,
);
for (handle, entity) in solved {
if let Some(slot) = self.tabs[i].scene.document.get_entity_mut(handle) {
*slot = entity;
}
}
}
pub(in crate::app) fn capture_grip_history_originals(&mut self, i: usize, handles: &[Handle]) {
if !self.grip_history_originals.is_empty() {
return;
}
self.grip_history_originals = handles
.iter()
.filter_map(|&handle| {
let objects = self.tabs[i].scene.solid_history_objects(handle);
(!objects.is_empty()).then_some((handle, objects))
})
.collect();
}
pub(in crate::app) fn cancel_active_grip_edit(&mut self) -> bool {
let i = self.active_tab;
// Grip-menu toggles live only as long as the gesture.
self.tabs[i].grip_copy = false;
self.tabs[i].grip_base_pending = false;
let had_grip = self.tabs[i].active_grip.take().is_some()
|| self.grip_add_provisional.is_some()
|| !self.grip_preview_handles.is_empty()
|| !self.grip_history_originals.is_empty();
if !had_grip {
return false;
}
// An Add-Leader arrow being placed is still provisional.
if let Some((handle, grip_id)) = self.grip_add_provisional.take() {
use crate::entities::traits::EntityTypeOps;
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
entity.apply_grip_menu(
grip_id,
crate::scene::model::object::GripMenuAction::RemoveLeader,
);
}
self.tabs[i]
.scene
.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]);
}
let handles = std::mem::take(&mut self.grip_preview_handles);
let originals = std::mem::take(&mut self.grip_originals);
let history_originals = std::mem::take(&mut self.grip_history_originals);
let history_handles: rustc_hash::FxHashSet<_> = history_originals
.iter()
.map(|(handle, _)| *handle)
.collect();
for (_, objects) in history_originals {
for (object_handle, object) in objects {
self.tabs[i]
.scene
.document
.objects
.insert(object_handle, object);
}
}
let mut changed_handles: rustc_hash::FxHashSet<_> = handles.iter().copied().collect();
for (handle, original) in originals {
changed_handles.insert(handle);
if !history_handles.contains(&handle) {
let current = self.tabs[i]
.scene
.document
.get_entity(handle)
.and_then(crate::entities::solid3d::point_of_reference)
.map(|point| [point.x, point.y, point.z]);
let target = crate::entities::solid3d::point_of_reference(&original)
.map(|point| [point.x, point.y, point.z]);
if let (Some(current), Some(target)) = (current, target) {
self.tabs[i].scene.translate_solid_geometry(
handle,
[
target[0] - current[0],
target[1] - current[1],
target[2] - current[2],
],
);
}
}
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
*entity = original;
}
}
for handle in history_handles {
let restored = self.tabs[i]
.scene
.document
.solid_history_operation(handle)
.cloned()
.is_some_and(|operation| {
self.tabs[i].scene.rebuild_solid_history(handle, operation)
});
if !restored {
self.tabs[i].scene.reseed_derived_caches(handle);
}
}
for &handle in &handles {
self.tabs[i].scene.preview_hidden.remove(&handle);
}
let changes: Vec<_> = changed_handles
.into_iter()
.map(|handle| (handle, crate::scene::ChangeKind::Modified))
.collect();
self.tabs[i]
.scene
.bump_entities_after_parametric_solve(&changes);
if let Some(dirty_before) = self.grip_dirty_before.take() {
self.tabs[i].dirty = dirty_before;
}
self.grip_reference_wires.clear();
self.grip_text_verts.clear();
self.grip_text_slide = false;
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].snap_result = None;
self.refresh_selected_grips();
self.refresh_properties();
true
}
/// End an interactive command before its drawing coordinate context
/// changes. This is a full interaction boundary, not only an `active_cmd`
/// check: grip drags, suspended editor commands, snaps, tracking, dynamic
/// input and pointer gestures all carry coordinates from the old space.
pub(in crate::app) fn cancel_active_command_for_space_change(&mut self) -> Task<Message> {
let i = self.active_tab;
let mut tasks = Vec::new();
let mut cancellation_reported = false;
if let Some(result) = self.tabs[i]
.active_cmd
.as_mut()
.map(|command| command.on_space_change())
{
let (result, message_pending) = match result {
CmdResult::Cancel => (CmdResult::CancelForSpaceChange, false),
other => (other, true),
};
tasks.push(self.apply_cmd_result(result));
// `on_space_change` must be terminal. Force a plain cancellation
// if an external/plugin command violates that contract.
if self.tabs[i].active_cmd.is_some() {
tasks.push(self.apply_cmd_result(CmdResult::CancelForSpaceChange));
} else if message_pending {
self.command_line.push_info(
crate::t!("Command cancelled because the active drawing space changed.")
.as_ref(),
);
}
cancellation_reported = true;
}
let grip_cancelled = self.cancel_active_grip_edit();
let suspended_cancelled = self.tabs[i].suspended_cmd.take().is_some();
let editor_cancelled = self.text_inline.is_some() || self.mtext_editor.is_some();
if editor_cancelled {
self.text_inline_cancel();
self.mtext_cancel();
}
if !cancellation_reported && (grip_cancelled || suspended_cancelled || editor_cancelled) {
self.command_line.push_info(
crate::t!("Command cancelled because the active drawing space changed.").as_ref(),
);
}
self.command_line.input.clear();
self.command_line.autocomplete_cursor = None;
self.command_line.close_history();
self.reset_space_interaction_state();
if tasks.is_empty() {
Task::none()
} else {
Task::batch(tasks)
}
}
/// Apply LIMCHECK/PLIMCHECK to a point before an interactive command
/// consumes it. LIMITS itself must be able to redefine a rectangle beyond
/// the old boundary, so it is the sole bypass.
pub(in crate::app) fn command_point_allowed(&mut self, i: usize, point: glam::DVec3) -> bool {
let checks_limits = self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|command| command.name() != "LIMITS")
&& self.tabs[i].scene.drawing_limit_check_enabled();
if checks_limits && !self.tabs[i].scene.point_inside_drawing_limits(point) {
self.command_line
.push_error(crate::t!("Outside limits.").as_ref());
return false;
}
true
}
/// Refresh screen-space feature picking immediately before point dispatch.
pub(in crate::app) fn refresh_command_point_pick_context(&mut self, i: usize) {
if self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|command| command.wants_point_pick_context())
{
let scene = &self.tabs[i].scene;
let size = scene.selection.borrow().view.vp_size;
let edit = scene.viewport_edit_frame(size);
let tile = edit
.as_ref()
.map(|(_, rect)| *rect)
.unwrap_or_else(|| scene.active_model_tile_bounds(size.0, size.1));
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: tile.width,
height: tile.height,
};
let context = if bounds.width > 0.0 && bounds.height > 0.0 {
let (view, eye) = if let Some((camera, _)) = edit {
(camera.view_proj_rte(bounds), camera.eye())
} else {
let camera = scene.camera.borrow();
(camera.view_proj_rte(bounds), camera.eye())
};
Some(crate::command::PointPickContext {
view,
eye,
bounds,
aperture_px: crate::ui::overlay::pick_box_aperture_px(self.pick_box),
})
} else {
None
};
if let Some(command) = self.tabs[i].active_cmd.as_mut() {
command.set_point_pick_context(context);
}
}
}
/// Drive the active command's step machine with one [`StepInput`], then
/// apply the result. This is the single entry point every input source
/// (command line, headless, dynamic input, plugin API, viewport) funnels
/// through, so the routing from input → `on_*` method → `apply_cmd_result`
/// lives in exactly one place. No-op when no command is active.
pub(in crate::app) fn feed_command(&mut self, input: StepInput) -> Task<Message> {
self.feed_command_consumed(input).0
}
/// Keeps only associative dimensions in a completed selection when the
/// active command asks for that, deselecting the rest and reporting how
/// many objects were dropped.
fn filter_associative_dimension_selection(&mut self, input: StepInput) -> StepInput {
let StepInput::SelectionComplete(handles) = input else {
return input;
};
let i = self.active_tab;
if !self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|command| command.selection_keeps_associative_dimensions())
{
return StepInput::SelectionComplete(handles);
}
let (kept, dropped): (Vec<Handle>, Vec<Handle>) = handles.into_iter().partition(|handle| {
// A dimension that already drives a constraint is not one to
// convert either.
crate::scene::parametric_constraints::dynamic_dimension_constraint(
&self.tabs[i].scene.parametric_constraints,
*handle,
)
.is_none()
&& crate::scene::dimension_assoc::constraint_from_associative_dimension(
&self.tabs[i].scene.document,
*handle,
)
.is_some()
});
for handle in &dropped {
self.tabs[i].scene.deselect_entity(*handle);
}
if !dropped.is_empty() {
let count = dropped.len();
self.command_line.push_info(
if count == 1 {
crate::t!("1 was filtered out.")
} else {
crate::tf!("{count} were filtered out.")
}
.as_ref(),
);
self.refresh_properties();
}
StepInput::SelectionComplete(kept)
}
/// Keeps only block references in a completed selection when the active
/// command asks for that (XCLIP), deselecting the rest and saying how many
/// were ineligible.
fn filter_block_reference_selection(&mut self, input: StepInput) -> StepInput {
let StepInput::SelectionComplete(handles) = input else {
return input;
};
let i = self.active_tab;
if !self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|command| command.selection_keeps_block_references())
{
return StepInput::SelectionComplete(handles);
}
let (kept, dropped): (Vec<Handle>, Vec<Handle>) = handles.into_iter().partition(|h| {
matches!(
self.tabs[i].scene.document.get_entity(*h),
Some(codec::EntityType::Insert(_))
)
});
for handle in &dropped {
self.tabs[i].scene.deselect_entity(*handle);
}
if !dropped.is_empty() {
let count = dropped.len();
self.command_line.push_info(&if count == 1 {
"1 was ineligible for clipping.".to_string()
} else {
format!("{count} were ineligible for clipping.")
});
self.refresh_properties();
}
StepInput::SelectionComplete(kept)
}
/// Keeps only the kinds of object the active command takes, dropping
/// the rest of a completed selection silently.
fn filter_command_entity_selection(&mut self, input: StepInput) -> StepInput {
let StepInput::SelectionComplete(handles) = input else {
return input;
};
let i = self.active_tab;
let Some(keep) = self.tabs[i]
.active_cmd
.as_ref()
.and_then(|command| command.selection_entity_filter())
else {
return StepInput::SelectionComplete(handles);
};
let (kept, dropped): (Vec<Handle>, Vec<Handle>) = handles
.into_iter()
.partition(|h| self.tabs[i].scene.document.get_entity(*h).is_some_and(keep));
for handle in &dropped {
self.tabs[i].scene.deselect_entity(*handle);
}
if !dropped.is_empty() {
self.refresh_properties();
}
StepInput::SelectionComplete(kept)
}
/// Drops locked point clouds from a completed selection when the active
/// command would move them, saying which were locked and how many went.
fn filter_locked_point_cloud_selection(&mut self, input: StepInput) -> StepInput {
let StepInput::SelectionComplete(handles) = input else {
return input;
};
let i = self.active_tab;
if !self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|command| command.selection_drops_locked_point_clouds())
{
return StepInput::SelectionComplete(handles);
}
let (dropped, kept): (Vec<Handle>, Vec<Handle>) = handles.into_iter().partition(|h| {
self.tabs[i]
.scene
.document
.get_entity(*h)
.is_some_and(crate::scene::is_locked_point_cloud)
});
for handle in &dropped {
self.tabs[i].scene.deselect_entity(*handle);
}
if !dropped.is_empty() {
let count = dropped.len();
self.command_line.push_info(&if count == 1 {
crate::t!("1 point cloud is locked.").into_owned()
} else {
crate::tf!("{count} point clouds are locked.").into_owned()
});
self.command_line.push_info(&if count == 1 {
crate::t!("1 was filtered out.").into_owned()
} else {
crate::tf!("{count} were filtered out.").into_owned()
});
self.refresh_properties();
}
StepInput::SelectionComplete(kept)
}
/// What one selection step picked, as the reference words it: `N found`
/// for the first step, `N found, M total`, `N found (D duplicate), M
/// total` when some were chosen already, `N found, R removed, M total` in
/// remove mode. Returns the command's key while it gathers.
fn report_selection_found(&mut self, input: &StepInput) -> Option<usize> {
let StepInput::SelectionComplete(handles) = input else {
return None;
};
// A pick belongs to this step only; never to a later one.
let last_pick = self.last_pick.take();
let command = self.tabs[self.active_tab].active_cmd.as_ref()?;
if !command.is_selection_gathering() {
return None;
}
let key = &**command as *const dyn crate::command::CadCommand as *const () as usize;
let previous: rustc_hash::FxHashSet<Handle> = match &self.selection_report {
Some((k, previous)) if *k == key => previous.iter().copied().collect(),
_ => Default::default(),
};
let now: rustc_hash::FxHashSet<Handle> = handles.iter().copied().collect();
let total = now.len();
// What the step picked: its own objects when the pick says so, else
// what the selection gained or lost.
let (picked, remove) = match last_pick {
Some((picked, remove)) => (picked.into_iter().collect(), remove),
None => {
let lost: rustc_hash::FxHashSet<Handle> =
previous.difference(&now).copied().collect();
if lost.is_empty() {
(now.difference(&previous).copied().collect(), false)
} else {
(lost, true)
}
}
};
let found = picked.len();
let known = picked.intersection(&previous).count();
let line = if found == 0 || previous.is_empty() && !remove {
crate::tf!("{found} found")
} else if remove && known > 0 {
crate::tf!("{found} found, {known} removed, {total} total")
} else if !remove && known > 0 {
crate::tf!("{found} found ({known} duplicate), {total} total")
} else {
crate::tf!("{found} found, {total} total")
};
self.command_line.push_info(line.as_ref());
Some(key)
}
/// `feed_command`, also reporting whether the step actually took the input.
/// Only a `Text` token can come back unclaimed (`on_text_input` returning
/// `None`), which is what lets the caller read it as something else — a
/// typed distance, say — instead of guessing beforehand.
pub(in crate::app) fn feed_command_consumed(
&mut self,
input: StepInput,
) -> (Task<Message>, bool) {
let gathering = self.report_selection_found(&input);
// A command that takes associative dimensions only keeps just those:
// the rest leave the selection and the count of what went is reported,
// as the reference does while gathering.
let input = self.filter_associative_dimension_selection(input);
let input = self.filter_block_reference_selection(input);
let input = self.filter_locked_point_cloud_selection(input);
let input = self.filter_command_entity_selection(input);
if let (Some(key), StepInput::SelectionComplete(kept)) = (gathering, &input) {
self.selection_report = Some((key, kept.clone()));
}
// XCLIP / CLIP learn which chosen references carry a clip already.
{
let i = self.active_tab;
let tab = &mut self.tabs[i];
if let Some(command) = tab.active_cmd.as_mut() {
let document = &tab.scene.document;
command.inject_clipped(&|h| {
crate::scene::pick::xclip::filter_handle(document, h).is_some()
});
}
}
// Selection keywords (P / PREVIOUS, L / LAST) consume the token
// before it reaches the command (#426).
if let StepInput::Text(s) = &input {
let kw_input = s.clone();
if let Some(task) = self.try_selection_keyword(&kw_input) {
return (task, true);
}
}
let i = self.active_tab;
let default_start = matches!(&input, StepInput::Enter)
&& self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|command| command.enter_accepts_default_start());
let input = if default_start {
StepInput::Point(self.default_draw_start(i))
} else {
input
};
if let StepInput::Point(point) = &input {
if !self.command_point_allowed(i, *point) {
return (Task::none(), true);
}
self.angle_override = None;
// Typed / dynamic-input / headless points carry no snap, but the
// accepted-snap list must stay index-parallel with the points the
// command collects. Interactive picks record themselves in
// the click handler and never reach here.
if !self.record_accepted_snap(i, None, None, *point) {
return (Task::none(), true);
}
self.snapper.clear_override();
}
if default_start {
let StepInput::Point(point) = &input else {
unreachable!("default command start must be a point");
};
self.last_point = Some(*point);
self.dyn_user_reshaped = false;
self.dyn_coord_absolute = false;
self.sync_dyn_fields();
self.reset_tracking_after_point();
self.push_ucs_to_cmd(i);
}
if let StepInput::EntityPick(handle, point) = &input {
if !self.dimension_acquisition_allowed(i, None) {
return (Task::none(), true);
}
let solid_pick = matches!(
self.tabs[i].scene.document.get_entity(*handle),
Some(
codec::EntityType::Solid3D(_)
| codec::EntityType::Surface(_)
| codec::EntityType::Region(_)
| codec::EntityType::Body(_)
)
);
let direction = if solid_pick {
self.tabs[i]
.scene
.solid_planar_face_normal_at(*handle, *point)
} else {
self.tabs[i]
.scene
.document
.get_entity(*handle)
.and_then(crate::entities::curve::entity_curve)
.and_then(|curve| curve.plane.normal())
.map(glam::DVec3::from_array)
};
if let Some(command) = self.tabs[i].active_cmd.as_mut() {
command.set_entity_pick_direction(direction);
}
if self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|command| command.inject_before_entity_pick())
{
if let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() {
if let Some(command) = self.tabs[i].active_cmd.as_mut() {
command.inject_picked_entity(entity);
}
}
}
}
if let StepInput::SelectionComplete(handles) = &input {
let exclude_locked = self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|command| command.selection_entities_exclude_locked());
let entities = {
let scene = &self.tabs[i].scene;
handles
.iter()
.filter(|handle| !exclude_locked || !scene.is_layer_locked(**handle))
.filter_map(|handle| {
scene.document.get_entity(*handle).cloned().map(|entity| {
let surface_area = scene
.meshes
.get(handle)
.or_else(|| scene.block_meshes.get(handle))
.map(|mesh| mesh.metrics.surface_area);
SelectionEntity {
handle: *handle,
entity,
surface_area,
}
})
})
.collect()
};
if let Some(command) = self.tabs[i].active_cmd.as_mut() {
command.inject_selection_entities(entities);
}
}
if matches!(&input, StepInput::Point(_)) {
self.refresh_command_point_pick_context(i);
}
let ctrl = self.ctrl_down;
let shift = self.shift_down;
let picked_handle = if let StepInput::EntityPick(h, _) = &input {
Some(*h)
} else {
None
};
let is_escape = matches!(&input, StepInput::Escape);
let keyed = matches!(&input, StepInput::Text(_) | StepInput::Enter);
let result: Option<CmdResult> = {
let Some(cmd) = self.tabs[i].active_cmd.as_mut() else {
return (Task::none(), false);
};
cmd.set_ctrl(ctrl);
cmd.set_shift(shift);
match input {
StepInput::Point(p) => Some(cmd.on_point(p)),
StepInput::Text(s) => cmd.on_text_input(&s),
StepInput::EntityPick(h, p) => Some(cmd.on_entity_pick(h, p)),
StepInput::StructurePick(h, p) => Some(cmd.on_structure_pick(h, p)),
StepInput::SelectionComplete(hs) => Some(cmd.on_selection_complete(hs)),
StepInput::Tangent(o, p) => Some(cmd.on_tangent_point(o, p)),
StepInput::EditorClosed(c) => Some(cmd.on_editor_closed(c)),
StepInput::Enter => Some(cmd.on_enter()),
StepInput::Escape => Some(cmd.on_escape()),
}
};
if let Some(handle) = picked_handle {
self.record_dimension_entity_points(i, None, handle, Vec::new());
}
self.sync_dimension_snaps(i);
if is_escape {
self.tabs[i].pending_pause_tokens = None;
}
match result {
Some(r) => {
let task = self.apply_cmd_result(r);
// A keyword can move the step without the cursor moving
// (PEDIT Next vertex): redraw its preview now (#1628).
if keyed && self.active_tab == i {
self.refresh_active_cmd_preview(i);
}
(task, true)
}
None => (Task::none(), false),
}
}
/// Process queued tokens buffered after a PAUSE (or `\`) until the queue is
/// empty or another PAUSE / cancellation is encountered.
pub(in crate::app) fn drain_pending_pause_tokens(&mut self, tab_idx: usize) -> Task<Message> {
let Some(mut tokens) = self.tabs[tab_idx].pending_pause_tokens.take() else {
return Task::none();
};
let mut tasks = Vec::new();
while !tokens.is_empty() {
if self.tabs[tab_idx].active_cmd.is_none() {
break;
}
let token = tokens.remove(0);
let trimmed = token.trim();
if trimmed.eq_ignore_ascii_case("PAUSE") || trimmed == "\\" {
self.tabs[tab_idx].pending_pause_tokens = Some(tokens);
break;
}
if trimmed.is_empty()
|| trimmed.eq_ignore_ascii_case("ENTER")
|| trimmed.eq_ignore_ascii_case("RETURN")
|| trimmed == "\n"
{
tasks.push(self.feed_command(StepInput::Enter));
} else if trimmed.eq_ignore_ascii_case("ESC")
|| trimmed.eq_ignore_ascii_case("ESCAPE")
|| trimmed.eq_ignore_ascii_case("CANCEL")
{
self.tabs[tab_idx].pending_pause_tokens = None;
tasks.push(self.feed_command(StepInput::Escape));
break;
} else {
tasks.push(self.feed_active_cmd(&token));
}
}
Task::batch(tasks)
}
/// Run one whole command-line string. A single word or an inline-argument
/// command (`PDMODE 3`, `LAYER Walls`, `UCS Z 90` pasted as one line)
/// dispatches as-is; for a multi-token line whose first word starts an
/// interactive tool (`LINE 0,0 10,10`) the first word starts the tool and the
/// remaining tokens are fed as points / option keywords, then the command is
/// terminated as if Enter were pressed. Shared by the GUI command line and
/// the headless automation feeder so both behave identically.
pub(in crate::app) fn run_command_line(&mut self, cmd: &str) -> Task<Message> {
self.run_command_line_streaming(cmd, true)
}
/// `run_command_line`, with the trailing Enter left off when `finish` is
/// false: the line is one instalment and the tool stays at its next prompt
/// for the following one. Only a plugin streaming a command feeds it that
/// way; the command line and the automation feeder always finish.
pub(in crate::app) fn run_command_line_streaming(
&mut self,
cmd: &str,
finish: bool,
) -> Task<Message> {
let i = self.active_tab;
self.command_line.unconsumed.clear();
let tokens: Vec<&str> = cmd.split_whitespace().collect();
if tokens.len() <= 1 {
return self.dispatch_command(cmd);
}
// Plugin commands parse their own inline arguments from the whole line
// (e.g. `HC_PIPE 2B 2C 1.25 0.013`), so offer the full command to plugin
// dispatch first. A built-in interactive tool matches only its bare name
// (`LINE`), so the full line is not a plugin command and falls through to
// the first-word + fed-tokens path below. (#162)
if !self.suppress_plugin_dispatch && crate::plugin::try_dispatch(self, i, cmd) {
self.tabs[i].last_cmd = Some(cmd.to_string());
self.command_line.record_recent(cmd);
let toks: Vec<String> = tokens.iter().map(|s| s.to_string()).collect();
return self.finish_active_command(&toks, finish);
}
// Bare XREF opens the External References palette, so probing the
// verb alone would open it (and refresh every entry) before the
// argument form `XREF Reload A` runs.
if tokens[0].eq_ignore_ascii_case("BACKGROUND")
|| tokens[0].eq_ignore_ascii_case("COLORSCHEME")
|| tokens[0].eq_ignore_ascii_case("XREF")
// `VIEW WINDOW <name> <x1,y1> <x2,y2>` takes its arguments in one line.
|| tokens[0].eq_ignore_ascii_case("VIEW")
{
return self.dispatch_command(cmd);
}
// Close an earlier step first so only what the probe opens is dropped below.
self.finish_pending_history(i);
let start_task = self.dispatch_command(tokens[0]);
if self.tabs[i].active_cmd.is_none() {
// Not an interactive tool — an inline-argument command (`PDMODE 3`).
// The bare word only probed it: a step it kept without changing
// anything is not an undo step of its own.
self.drop_unchanged_pending_history(i);
return self.dispatch_command(cmd);
}
let toks: Vec<String> = tokens.iter().map(|s| s.to_string()).collect();
let finish_task = self.finish_active_command(&toks, finish);
Task::batch([start_task, finish_task])
}
/// Feed `tokens[1..]` to the active interactive command as points / option
/// keywords, then terminate it as if Enter were pressed. No-op when no
/// command is active.
///
/// Two things happen when the tokens run out mid-way, both needed so a
/// headless caller can tell what actually happened:
///
/// * If the in-place text editor took over (the `TEXT` content step — the
/// command itself has already ended by then, `active_cmd` is `None`), the
/// remaining tokens are that text: they are typed into the editor and
/// committed, the same messages the control surface's `text_input` /
/// `text_commit` actions dispatch. Without this the tail of the line was
/// silently dropped and `TEXT 0,0 5 0 hi` created nothing at all.
/// * Anything still left over was never claimed by any prompt; it is
/// recorded in [`CommandLine::unconsumed`] instead of vanishing.
pub(in crate::app) fn finish_active_command(
&mut self,
tokens: &[String],
finish: bool,
) -> Task<Message> {
let i = self.active_tab;
if self.tabs[i].active_cmd.is_none() {
return Task::none();
}
self.last_point = None;
// An editor that is *already* open belongs to someone else (an earlier
// line that stopped at the content step). Only the editor this line
// opens by feeding its own tokens may be handed the tail.
let editor_was_open = self.text_inline.is_some();
let mut tasks = Vec::new();
// First token is the command verb itself; prompts start consuming after it.
let mut consumed = 1;
let mut paused = false;
for (idx, tok) in tokens[1..].iter().enumerate() {
if self.tabs[i].active_cmd.is_none() {
break;
}
let trimmed = tok.trim();
if trimmed.eq_ignore_ascii_case("PAUSE") || trimmed == "\\" {
let mut remainder: Vec<String> = tokens[1 + idx + 1..].to_vec();
// The line still ends with an Enter; it waits behind the pause
// instead of being dropped with it.
if finish {
remainder.push("ENTER".to_string());
}
self.tabs[i].pending_pause_tokens = Some(remainder);
consumed = tokens.len();
paused = true;
break;
}
tasks.push(self.feed_active_cmd(tok));
consumed += 1;
}
if !editor_was_open && self.text_inline.is_some() && consumed < tokens.len() {
// Fill the editor the same way `Message::TextInlineInput` does, then
// commit *synchronously* so the outcome is observable: dispatching
// `TextInlineOk` as a task would hide whether the entity was really
// created, and the tail token would be counted as consumed either
// way — the caller would have no way to tell "text created" from
// "text silently dropped".
if let Some(editor) = self.text_inline.as_mut() {
editor.value = tokens[consumed..].join(" ");
}
let committed = self.text_inline_commit();
tasks.push(self.post_editor_closed(committed));
if committed {
consumed = tokens.len();
// `TEXT` repeats by design: on a committed string it re-arms for
// the next line (`open_next_line`), which re-opens the editor. A
// batch line is a complete unit, and leaving that repeat armed
// would make the *next* line get eaten as the next text position,
// so end the command here, exactly as Esc does in the GUI.
tasks.push(Task::done(Message::CommandEscape));
}
// On a failed commit the tokens stay unconsumed (assigned below), so
// the caller still sees the text it passed in.
}
if consumed < tokens.len() {
self.command_line.unconsumed = tokens[consumed..].to_vec();
}
if !paused && finish {
tasks.push(self.feed_command(StepInput::Enter));
}
Task::batch(tasks)
}
/// Classify one typed token into a [`StepInput`] and route it through the
/// shared [`Self::feed_command`]. An object-pick step takes a hex handle; a
/// coordinate is parsed (and, like the GUI command line, interpreted in the
/// active UCS); anything else is an option keyword / value. Used by both the
/// GUI command line and headless automation.
/// Standard selection keywords at a "Select objects:" prompt (#426):
/// P / PREVIOUS re-selects the set the last command worked on, and
/// L / LAST selects the most recently created object in the current
/// space. Returns `Some` when the token was consumed as a keyword.
/// Handled centrally so every gathering command gets them for free.
pub(in crate::app) fn try_selection_keyword(&mut self, text: &str) -> Option<Task<Message>> {
let i = self.active_tab;
let gathering = self.tabs[i]
.active_cmd
.as_ref()
.map_or(false, |c| c.is_selection_gathering());
if !gathering {
return None;
}
let kw = text.trim().to_ascii_uppercase();
// Modes that arm the next gesture rather than selecting anything now.
// Dragging normally decides window-vs-crossing from the direction the
// corner travels; asking for one by name has to override that, and hold
// the override until the box is finished. (#596)
if let "W" | "WINDOW" | "C" | "CROSSING" = kw.as_str() {
let crossing = matches!(kw.as_str(), "C" | "CROSSING");
{
let mut selection = self.tabs[i].scene.selection.borrow_mut();
selection.gesture.box_crossing = crossing;
selection.gesture.box_crossing_locked = true;
}
let hint = if crossing {
crate::t!("Crossing: specify first corner.")
} else {
crate::t!("Window: specify first corner.")
};
self.command_line.push_info(hint.as_ref());
return Some(Task::none());
}
// Whether a pick adds to the set or takes away from it, for the rest of
// this selection. Mirrors PICKADD, which the pick paths already read.
if let "R" | "REMOVE" | "A" | "ADD" = kw.as_str() {
let adding = matches!(kw.as_str(), "A" | "ADD");
self.select_remove_mode = !adding;
let hint = if adding {
crate::t!("Add mode: picks join the selection.")
} else {
crate::t!("Remove mode: picks leave the selection.")
};
self.command_line.push_info(hint.as_ref());
return Some(Task::none());
}
let add: Vec<Handle> = match kw.as_str() {
// Every selectable object of the current space.
"ALL" => self.tabs[i]
.scene
.entity_wires()
.iter()
.filter_map(|w| crate::scene::Scene::handle_from_wire_name(&w.name))
.collect(),
"P" | "PREVIOUS" => self.tabs[i]
.prev_selection
.iter()
.copied()
.filter(|&h| self.tabs[i].scene.document.get_entity(h).is_some())
.collect(),
// Highest handle among the selectable wires of the current space —
// handles are handed out monotonically, so that is the most
// recently created object.
"L" | "LAST" => self.tabs[i]
.scene
.entity_wires()
.iter()
.filter_map(|w| crate::scene::Scene::handle_from_wire_name(&w.name))
.max_by_key(|h| h.value())
.into_iter()
.collect(),
_ => return None,
};
if add.is_empty() {
self.command_line.push_info(
crate::t!(match kw.as_str() {
"P" | "PREVIOUS" => "No previous selection set.",
"ALL" => "Nothing to select.",
_ => "No last object.",
})
.as_ref(),
);
return Some(Task::none());
}
self.last_pick = Some((add.clone(), false));
for h in add {
self.tabs[i].scene.select_entity(h, false);
}
self.refresh_properties();
let handles: Vec<Handle> = self.tabs[i]
.scene
.selected_entities()
.into_iter()
.map(|(h, _)| h)
.collect();
Some(self.feed_command(StepInput::SelectionComplete(handles)))
}
pub(in crate::app) fn feed_active_cmd(&mut self, token: &str) -> Task<Message> {
let i = self.active_tab;
// Object-pick step: the token is a handle (as returned by `query`).
if self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|c| c.needs_entity_pick())
{
// Option keywords take precedence over the handle reading —
// "F"/"C"/"E" are valid hex, but during TRIM/EXTEND they are the
// Fence/Crossing/Edge options (#336). Only an unconsumed token
// falls through to the handle interpretation.
let consumed = self.tabs[i]
.active_cmd
.as_mut()
.and_then(|c| c.on_text_input(token));
if let Some(r) = consumed {
return self.apply_cmd_result(r);
}
let accepts_points = self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|command| command.entity_pick_accepts_points());
if accepts_points {
if let Some((coord, kind)) = crate::app::helpers::parse_coord(token) {
let ucs = self.tabs[i].active_ucs.clone();
let wcs = match (
matches!(kind, crate::app::helpers::CoordKind::Relative),
self.last_point,
) {
(true, Some(base)) => {
base + match &ucs {
Some(value) => crate::app::helpers::ucs_rotate_vec(coord, value),
None => coord,
}
}
_ => match &ucs {
Some(value) => crate::app::helpers::ucs_to_wcs(coord, value),
None => coord,
},
};
if self.command_point_allowed(i, wcs) {
self.last_point = Some(wcs);
self.push_ucs_to_cmd(i);
// A typed coordinate at an object prompt is a pick at
// that point, as in the reference.
let picks_entity = self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|command| command.typed_point_picks_entity());
if picks_entity {
let owner = self.tabs[i]
.current_parametric_scope()
.owner_handle(&self.tabs[i].scene.document);
let handle =
entity_at_typed_point(&self.tabs[i].scene.document, owner, wcs)
.unwrap_or(Handle::NULL);
return self.feed_command(StepInput::EntityPick(handle, wcs));
}
return self.feed_command(StepInput::Point(wcs));
}
return Task::none();
}
}
if let Ok(v) = u64::from_str_radix(token.trim_start_matches("0x"), 16) {
let handle = Handle::new(v);
let pt = self.tabs[i]
.scene
.document
.get_entity(handle)
.map(|e| {
let bb = e.as_entity().bounding_box();
glam::Vec3::new(
((bb.min.x + bb.max.x) * 0.5) as f32,
((bb.min.y + bb.max.y) * 0.5) as f32,
0.0,
)
})
.unwrap_or(glam::Vec3::ZERO);
return self.feed_command(StepInput::EntityPick(handle, pt.as_dvec3()));
}
return Task::none();
}
// Keywords, distances and points fed here (option buttons, the
// context menu, scripts) join the Recent Input list like typed ones.
self.command_line.record_recent_input(token);
let is_mtp = token.trim_start_matches('_').eq_ignore_ascii_case("MTP")
|| token.trim_start_matches('_').eq_ignore_ascii_case("M2P");
if is_mtp {
if self.point_modifier_step(i) {
self.start_mtp_modifier(i);
return Task::none();
}
}
if let Some(kind) = self.typed_snap_override(i, token) {
self.apply_snap_override(kind);
return Task::none();
}
if self.try_angle_override(token) {
return Task::none();
}
if let Some((coord, kind)) = crate::app::helpers::parse_coord(token) {
// Match the GUI command line: typed coordinates are in the active
// UCS (relative offsets are rotated by the UCS axes), so a multi-
// token `LINE 0,0 10,10` under a rotated UCS lands correctly.
let ucs = self.tabs[i].active_ucs.clone();
let wcs = match (
matches!(kind, crate::app::helpers::CoordKind::Relative),
self.last_point,
) {
(true, Some(base)) => {
base + match &ucs {
Some(u) => crate::app::helpers::ucs_rotate_vec(coord, u),
None => coord,
}
}
_ => match &ucs {
Some(u) => crate::app::helpers::ucs_to_wcs(coord, u),
None => coord,
},
};
if !self.command_point_allowed(i, wcs) {
return Task::none();
}
self.last_point = Some(wcs);
self.push_ucs_to_cmd(i);
return self.feed_command(StepInput::Point(wcs));
} else {
// The step reads the token first: at a great many point prompts a
// bare number is the step's own value, not a distance — ROTATE's
// angle, SCALE's factor, CIRCLE's radius. Only a token no step
// claims becomes direct distance entry, measured along the cursor
// direction from the command's anchor. Under a typed `<angle`
// the number is the distance along it first.
if self.angle_override.is_some() {
if let Some(distance) = self.try_direct_distance_entry(token) {
return distance;
}
}
let (task, consumed) = self.feed_command_consumed(StepInput::Text(token.to_string()));
if consumed {
return task;
}
if let Some(distance) = self.try_direct_distance_entry(token) {
return distance;
}
return task;
}
}
/// Feed one line from a command script (.scr) into the command pipeline.
///
/// If an interactive command is active, the line (or its tokens) is routed to
/// the active command's prompts (e.g. coordinates or options). If no command
/// is active, the line starts a new command (supporting both bare verbs and
/// inline single-line commands).
pub(in crate::app) fn feed_script_line(&mut self, line: &str) -> Task<Message> {
let i = self.active_tab;
let trimmed = line.trim();
// 1. Text editor currently open (e.g. TEXT command content step).
if self.text_inline.is_some() {
if let Some(editor) = self.text_inline.as_mut() {
editor.value = line.to_string();
}
let committed = self.text_inline_commit();
let task = self.post_editor_closed(committed);
return if committed {
Task::batch([task, Task::done(Message::CommandEscape)])
} else {
task
};
}
// 2. Blank line submits Enter to the active command (or repeats last).
if trimmed.is_empty() {
if self.tabs[i].active_cmd.is_some() {
return self.feed_command(StepInput::Enter);
}
// Same repeat the command line's empty Enter gives.
if let Some(cmd) = self.tabs[i].last_cmd.clone() {
return self.dispatch_command(&cmd);
}
return Task::none();
}
// 3. A command is currently active: route to its prompt handlers.
if self.tabs[i].active_cmd.is_some() {
if self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.input_kind().is_free_text())
.unwrap_or(false)
{
return self.feed_command(StepInput::Text(trimmed.to_string()));
}
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
let mut tasks = Vec::new();
for tok in tokens {
if self.tabs[i].active_cmd.is_none() {
// Preceding token finished the active command; remaining tokens start a new one.
tasks.push(self.run_command_line(tok));
} else {
tasks.push(self.feed_active_cmd(tok));
}
}
return Task::batch(tasks);
}
// 4. No command is active: run as top-level command.
self.run_command_line(trimmed)
}
}