forked from 1b-agent/opencadstudio-upstream
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]>
1330 lines
56 KiB
Rust
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(®ions);
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|