diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs deleted file mode 100644 index 38affcb23..000000000 --- a/src/app/command_driver.rs +++ /dev/null @@ -1,11620 +0,0 @@ -use super::{Message, OpenCADStudio}; -use crate::command::{CmdResult, SelectionEntity, StepInput}; -use acadrust::Handle; -use iced::Task; - -/// `old` as a whole identifier in `source` becomes `new`. -fn replace_identifier(source: &str, old: &str, new: &str) -> String { - let mut out = String::with_capacity(source.len()); - let mut token = String::new(); - let flush = |token: &mut String, out: &mut String| { - if !token.is_empty() { - out.push_str(if token == old { new } else { token.as_str() }); - token.clear(); - } - }; - for c in source.chars() { - if c.is_alphanumeric() || c == '_' { - token.push(c); - } else { - flush(&mut token, &mut out); - out.push(c); - } - } - flush(&mut token, &mut out); - out -} - -impl OpenCADStudio { - pub(super) 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(super) 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: &acadrust::EntityType| { - let last_grip = match entity { - acadrust::EntityType::Line(line) => { - return Some(glam::DVec3::new(line.end.x, line.end.y, line.end.z)); - } - acadrust::EntityType::Arc(_) => Some(2), - acadrust::EntityType::LwPolyline(polyline) => { - polyline.vertices.len().checked_sub(1) - } - acadrust::EntityType::Polyline(polyline) => polyline.vertices.len().checked_sub(1), - acadrust::EntityType::Polyline2D(polyline) => { - polyline.vertices.len().checked_sub(1) - } - acadrust::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(super) 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.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(super) 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(super) 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(super) 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(super) fn cancel_active_command_for_space_change(&mut self) -> Task { - 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(super) 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(super) 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().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(super) fn feed_command(&mut self, input: StepInput) -> Task { - 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, Vec) = 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) - } - - /// `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(super) fn feed_command_consumed(&mut self, input: StepInput) -> (Task, bool) { - // 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); - // 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); - } - // 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); - } - } - 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( - acadrust::EntityType::Solid3D(_) - | acadrust::EntityType::Surface(_) - | acadrust::EntityType::Region(_) - | acadrust::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 result: Option = { - 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) => (self.apply_cmd_result(r), 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(super) fn drain_pending_pause_tokens(&mut self, tab_idx: usize) -> Task { - 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(super) fn run_command_line(&mut self, cmd: &str) -> Task { - 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(super) fn run_command_line_streaming(&mut self, cmd: &str, finish: bool) -> Task { - 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) { - let toks: Vec = 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") - { - return self.dispatch_command(cmd); - } - 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`). - return self.dispatch_command(cmd); - } - let toks: Vec = 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(super) fn finish_active_command(&mut self, tokens: &[String], finish: bool) -> Task { - 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 = 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(super) fn try_selection_keyword(&mut self, text: &str) -> Option> { - 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.box_crossing = crossing; - selection.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 = 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()); - } - let count = add.len(); - for h in add { - self.tabs[i].scene.select_entity(h, false); - } - self.command_line - .push_info(crate::tf!("{count} object(s) added to selection.").as_ref()); - self.refresh_properties(); - let handles: Vec = self.tabs[i] - .scene - .selected_entities() - .into_iter() - .map(|(h, _)| h) - .collect(); - Some(self.feed_command(StepInput::SelectionComplete(handles))) - } - - pub(super) fn feed_active_cmd(&mut self, token: &str) -> Task { - 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)) = super::helpers::parse_coord(token) { - let ucs = self.tabs[i].active_ucs.clone(); - let wcs = match ( - matches!(kind, super::helpers::CoordKind::Relative), - self.last_point, - ) { - (true, Some(base)) => { - base + match &ucs { - Some(value) => super::helpers::ucs_rotate_vec(coord, value), - None => coord, - } - } - _ => match &ucs { - Some(value) => super::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 { - let is_point_step = !self.tabs[i] - .active_cmd - .as_ref() - .map(|c| c.input_kind().wants_text()) - .unwrap_or(true) - || self.tabs[i] - .active_cmd - .as_ref() - .map(|c| c.point_step_accepts_keywords()) - .unwrap_or(false); - let not_entity_pick = !self.tabs[i] - .active_cmd - .as_ref() - .map(|c| c.needs_entity_pick()) - .unwrap_or(false); - if is_point_step && not_entity_pick { - self.start_mtp_modifier(i); - return Task::none(); - } - } - if let Some((coord, kind)) = super::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, super::helpers::CoordKind::Relative), - self.last_point, - ) { - (true, Some(base)) => { - base + match &ucs { - Some(u) => super::helpers::ucs_rotate_vec(coord, u), - None => coord, - } - } - _ => match &ucs { - Some(u) => super::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. - 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; - } - } - - /// Applies one command result, then — when that result ended the active - /// command — drops the selection. Editing tools (MOVE, COPY, ROTATE, …) and - /// every other interactive command leave nothing selected once they finish, - /// so a follow-up edit doesn't silently reuse the previous working set. - /// - /// `Relaunch`/`Dispatch` are excepted: they end the front-end command only - /// to immediately start another and hand it a deliberate selection (the - /// pick-first selector relaunching MOVE on the picked set works this way). - /// Pure-selection commands (SELECTALL, QSELECT, …) run without an active - /// command, so `was_active` is false and their selection is preserved. - pub(super) fn apply_cmd_result(&mut self, mut result: CmdResult) -> Task { - let i = self.active_tab; - if let CmdResult::ReturnPoint(pt) = result { - self.tabs[i].scene.clear_preview_wire(); - if let Some(mut suspended) = self.tabs[i].suspended_cmd.take() { - self.last_point = Some(pt); - let next_res = suspended.on_point(pt); - self.tabs[i].active_cmd = Some(suspended); - result = next_res; - } else { - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - return Task::none(); - } - } - let settings = self.tabs[self.active_tab] - .active_cmd - .as_ref() - .and_then(|command| command.sketch_settings()); - if let Some((sketch_type, increment, tolerance)) = settings { - let tab = &mut self.tabs[self.active_tab]; - let changed = tab.scene.document.header.sketch_type != sketch_type - || (tab.scene.document.header.sketch_increment - increment).abs() > f64::EPSILON - || (tab.scene.document.header.sketch_tolerance - tolerance).abs() > f64::EPSILON; - if changed { - crate::io::set_sketch_settings( - &mut tab.scene.document, - sketch_type, - increment, - tolerance, - ); - tab.dirty = true; - } - } - let mline_settings = self.tabs[self.active_tab] - .active_cmd - .as_ref() - .and_then(|command| command.mline_settings()); - if let Some((scale, justification, style_name, style_handle)) = mline_settings { - let tab = &mut self.tabs[self.active_tab]; - let header = &mut tab.scene.document.header; - let style_handle = style_handle.unwrap_or(acadrust::Handle::NULL); - let changed = (header.multiline_scale - scale).abs() > f64::EPSILON - || header.multiline_justification != justification - || !header.multiline_style.eq_ignore_ascii_case(&style_name) - || header.current_multiline_style_handle != style_handle; - if changed { - header.multiline_scale = scale; - header.multiline_justification = justification; - header.multiline_style = style_name; - header.current_multiline_style_handle = style_handle; - tab.dirty = true; - } - } - let was_active = self.tabs[self.active_tab].active_cmd.is_some(); - let preserve_selection = matches!( - &result, - CmdResult::Relaunch(..) - | CmdResult::Dispatch(..) - | CmdResult::SolidEdgeBlend { .. } - | CmdResult::SolidSubtract { .. } - | CmdResult::SliceEntities { .. } - | CmdResult::SliceSurfaceEntities { .. } - ); - let task = self.apply_cmd_result_inner(result); - let i = self.active_tab; - // A transparent zoom that just finished hands control back to the - // command it interrupted before the "command ended" bookkeeping below. - self.resume_transparent_parent(i); - let preview_hidden = self.tabs[i] - .active_cmd - .as_ref() - .map(|command| command.preview_hidden_handles().to_vec()) - .unwrap_or_default(); - self.tabs[i] - .scene - .set_command_preview_hidden(&preview_hidden); - if was_active - && !preserve_selection - && self.tabs[i].active_cmd.is_none() - && !self.tabs[i].scene.selected.is_empty() - { - // Remember the working set for the "Previous" selection keyword - // (#426) before dropping it. - self.tabs[i].prev_selection = self.tabs[i].scene.selected.iter().copied().collect(); - self.tabs[i].scene.deselect_all(); - self.refresh_properties(); - } - // A command just ended (any terminal result) — if it was the draw - // command an ADDSELECTED launched, revert the template-property override - // so CLAYER / CECOLOR / … are left unchanged (#239). No-op otherwise. - if was_active && self.tabs[i].active_cmd.is_none() { - self.tabs[i].scene.set_hover_highlight(None); - self.command_line.set_step_options(Vec::new()); - self.restore_add_selected_defaults(); - self.tabs[i].pending_pause_tokens = None; - } - if self.tabs[i].active_cmd.is_some() && self.tabs[i].pending_pause_tokens.is_some() { - let drain_task = self.drain_pending_pause_tokens(i); - Task::batch([task, drain_task]) - } else { - task - } - } - - /// Resolve the cell under `click` on table `handle` (or any table in the drawing - /// if `handle` is `Handle::NULL`), arm the cell indicator, and launch the cell - /// editor unless the cell's content is locked. Shared by the double-click shortcut - /// and the TABLEDIT point pick so both agree on grid coordinates and editor setup. - pub(crate) fn begin_table_cell_edit( - &mut self, - i: usize, - handle: acadrust::Handle, - click: glam::DVec3, - ) -> crate::modules::annotate::table_cmd::TableCellEditStart { - use crate::modules::annotate::table_cmd::{table_cell_at, TableCellEditStart}; - let target = if handle != acadrust::Handle::NULL { - self.tabs[i] - .scene - .document - .get_entity(handle) - .and_then(|entity| match entity { - acadrust::EntityType::Table(table) => Some((handle, table.clone())), - _ => None, - }) - } else { - // Find all tables whose grid contains `click`. - // Iterating document.entities() visits entities in draw order; - // taking the last match selects the table on top if they overlap. - let mut matched = None; - for entity in self.tabs[i].scene.document.entities() { - if let acadrust::EntityType::Table(table) = entity { - let h = table.common.handle; - let style = table.table_style_handle.and_then(|style_handle| { - self.tabs[i] - .scene - .document - .objects - .get(&style_handle) - .and_then(|object| match object { - acadrust::objects::ObjectType::TableStyle(style) => Some(style), - _ => None, - }) - }); - if table_cell_at(table, style, click).is_some() { - matched = Some((h, table.clone())); - } - } - } - matched - }; - let Some((handle, table)) = target else { - return TableCellEditStart::NoCell; - }; - let style = table.table_style_handle.and_then(|style_handle| { - self.tabs[i] - .scene - .document - .objects - .get(&style_handle) - .and_then(|object| match object { - acadrust::objects::ObjectType::TableStyle(style) => Some(style), - _ => None, - }) - }); - let Some(hit) = table_cell_at(&table, style, click) else { - return TableCellEditStart::NoCell; - }; - // Arm the Properties cell indicator whether or not the cell turns - // out to be editable (matches the double-click behavior). - let index = hit.index(&table); - self.tabs[i].properties.prop_vertex = index; - self.tabs[i].properties.prop_vertex_indicator_active = true; - crate::entities::table::set_prop_current_cell(index); - self.refresh_properties(); - if hit.locked { - return TableCellEditStart::LockedCell; - } - let command = crate::modules::annotate::table_cmd::TableCellEditCommand::new( - handle, &table, hit.row, hit.column, - ); - self.command_line - .push_info(&crate::command::CadCommand::prompt(&command)); - self.tabs[i].active_cmd = Some(Box::new(command)); - TableCellEditStart::Started - } - - /// Reports a refused command-line value and shows the prompt again. - fn reprompt_active_command(&mut self, i: usize, message: &str) { - self.command_line.push_error(message); - if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()) { - self.command_line.push_info(&prompt); - } - } - - /// Drops what removed dimensional constraints leave behind: their dynamic - /// dimensions, and their parameters when no other constraint reads them. - pub(crate) fn purge_dimensional_extras( - &mut self, - i: usize, - dimensions: Vec, - parameters: Vec, - ) { - if !dimensions.is_empty() { - self.tabs[i].scene.erase_entities(&dimensions); - } - let unused: Vec = parameters - .into_iter() - .filter(|name| { - !self.tabs[i] - .scene - .parametric_constraints - .iter() - .flat_map(|set| set.constraints.iter()) - .any(|constraint| { - matches!( - &constraint.driving_param, - Some(crate::scene::named_parameters::DrivingValue::Named(used)) - if used == name - ) - }) - }) - .collect(); - if !unused.is_empty() { - self.tabs[i].scene.record_undo_named_parameters_before(); - for name in &unused { - self.tabs[i].scene.named_parameters_mut().remove(name); - } - } - } - - /// `Dynamic` or `Annotational`, the DCFORM setting. - pub(crate) fn constraint_form_name(&self) -> &'static str { - if self.constraint_form_annotational { - "Annotational" - } else { - "Dynamic" - } - } - - /// The value prompt a double-clicked constraint dimension opens. - pub(crate) fn dynamic_dimension_value_command( - &self, - i: usize, - handle: Handle, - ) -> Option { - use crate::scene::named_parameters::DrivingValue; - let scene = &self.tabs[i].scene; - for set in &scene.parametric_constraints { - let Some((id, _)) = set - .dimensions - .iter() - .find(|(_, dimension)| **dimension == handle) - else { - continue; - }; - let constraint = set.get(*id)?; - let Some(DrivingValue::Named(name)) = &constraint.driving_param else { - return None; - }; - let table = if set.local_parameters.is_empty() { - scene.named_parameters() - } else { - &set.local_parameters - }; - let current = table - .get(name) - .map(|parameter| parameter.source.trim().to_string()) - .unwrap_or_default(); - return Some(crate::modules::parametric::DimensionValueCommand::new( - name.clone(), - current, - )); - } - None - } - - /// Applies `expression` or `newname=expression` to parameter `name`: - /// the dimension value prompt and -PARAMETERS Edit. - pub(crate) fn apply_parameter_input(&mut self, i: usize, name: &str, input: &str) { - let input = input.trim(); - let (target, expression) = match input.split_once('=') { - Some((new_name, expression)) => { - (new_name.trim().to_string(), expression.trim().to_string()) - } - None => (name.to_string(), input.to_string()), - }; - if expression.is_empty() { - return; - } - if target != name { - if let Err(error) = self.rename_parameter(i, name, &target) { - self.command_line.push_error(&error); - return; - } - } - let mut table = self.tabs[i].scene.named_parameters().clone(); - if let Err(error) = table.set(&target, &expression) { - self.command_line.push_error(&error.to_string()); - return; - } - if !table - .resolve(&target) - .is_ok_and(|value| value.is_finite()) - { - self.command_line.push_error("Invalid expression."); - return; - } - let pending = self.begin_undo(i, "Edit parameter", 0, true); - self.tabs[i].scene.record_undo_named_parameters_before(); - self.tabs[i].scene.named_parameters = table; - self.resolve_named_parameter_edit(i, &target); - self.tabs[i].scene.refresh_dynamic_dimension_texts(); - self.tabs[i].dirty = true; - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - - /// -PARAMETERS New. - pub(crate) fn create_parameter(&mut self, i: usize, name: &str, expression: &str) { - if self.tabs[i].scene.named_parameters().contains(name) { - self.command_line - .push_error(&format!("Parameter {name} already exists.")); - return; - } - let mut table = self.tabs[i].scene.named_parameters().clone(); - if let Err(error) = table.set(name, expression) { - self.command_line.push_error(&error.to_string()); - return; - } - let pending = self.begin_undo(i, "New parameter", 0, true); - self.tabs[i].scene.record_undo_named_parameters_before(); - self.tabs[i].scene.named_parameters = table; - self.tabs[i].scene.sync_native_parametric_graph(); - self.tabs[i].dirty = true; - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - - /// Renames a parameter everywhere: the table, the expressions that use - /// it and the constraints it drives. - pub(crate) fn rename_parameter(&mut self, i: usize, old: &str, new: &str) -> Result<(), String> { - use crate::scene::named_parameters::{is_reserved_name, is_valid_name, DrivingValue}; - let table = self.tabs[i].scene.named_parameters().clone(); - let Some(source) = table.get(old).map(|parameter| parameter.source.clone()) else { - return Err(format!("Parameter {old} not found.")); - }; - if !is_valid_name(new) || is_reserved_name(new) || table.contains(new) { - return Err(format!("Invalid parameter name {new}.")); - } - let mut renamed = table.clone(); - renamed.set(new, &source).map_err(|error| error.to_string())?; - for parameter in table.iter() { - if parameter.name == old { - continue; - } - let rewritten = replace_identifier(¶meter.source, old, new); - if rewritten != parameter.source { - renamed - .set(¶meter.name, &rewritten) - .map_err(|error| error.to_string())?; - } - } - renamed.remove(old); - let pending = self.begin_undo(i, "Rename parameter", 0, true); - self.tabs[i].scene.record_undo_named_parameters_before(); - self.tabs[i].scene.named_parameters = renamed; - let scopes: Vec<_> = self.tabs[i] - .scene - .parametric_constraints - .iter() - .map(|set| set.scope) - .collect(); - for scope in scopes { - let before = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .filter(|set| { - set.constraints.iter().any(|constraint| { - matches!(&constraint.driving_param, Some(DrivingValue::Named(name)) if name == old) - }) - }) - .cloned(); - let Some(before) = before else { - continue; - }; - self.tabs[i] - .scene - .record_undo_parametric_constraints_before(scope, before); - let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); - for constraint in &mut set.constraints { - if matches!(&constraint.driving_param, Some(DrivingValue::Named(name)) if name == old) { - constraint.driving_param = Some(DrivingValue::Named(new.to_string())); - } - } - } - self.tabs[i].scene.refresh_dynamic_dimension_texts(); - self.tabs[i].scene.sync_native_parametric_graph(); - self.tabs[i].dirty = true; - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - Ok(()) - } - - /// -PARAMETERS Delete: a parameter no constraint drives with. - pub(crate) fn delete_parameter(&mut self, i: usize, name: &str) { - use crate::scene::named_parameters::DrivingValue; - if !self.tabs[i].scene.named_parameters().contains(name) { - self.command_line - .push_error(&format!("Parameter {name} not found.")); - return; - } - let in_use = self.tabs[i] - .scene - .parametric_constraints - .iter() - .flat_map(|set| set.constraints.iter()) - .any(|constraint| { - matches!(&constraint.driving_param, Some(DrivingValue::Named(used)) if used == name) - }); - if in_use { - self.command_line - .push_error(&format!("Parameter {name} is used by a constraint.")); - return; - } - let pending = self.begin_undo(i, "Delete parameter", 0, true); - self.tabs[i].scene.record_undo_named_parameters_before(); - self.tabs[i].scene.named_parameters_mut().remove(name); - self.tabs[i].scene.sync_native_parametric_graph(); - self.tabs[i].dirty = true; - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - - fn remove_parametric_constraint( - &mut self, - id: crate::scene::parametric_constraints::ConstraintId, - label: &'static str, - ) -> bool { - let i = self.active_tab; - let scope = self.tabs[i].current_parametric_scope(); - let Some(set) = self.tabs[i].scene.parametric_constraint_set(scope) else { - return false; - }; - let Some(constraint) = set.get(id) else { - return false; - }; - let touched: Vec = constraint.refs.iter().map(|r| r.entity).collect(); - let dimension = set.dimensions.get(&id).copied(); - let parameter = match &constraint.driving_param { - Some(crate::scene::named_parameters::DrivingValue::Named(name)) => Some(name.clone()), - _ => None, - }; - let constraints_before = set.clone(); - let pending = self.begin_undo(i, label, touched.len(), dimension.is_none()); - self.tabs[i] - .scene - .record_undo_parametric_constraints_before(scope, constraints_before); - self.tabs[i] - .scene - .parametric_constraint_set_mut(scope) - .remove(id); - self.purge_dimensional_extras( - i, - dimension.into_iter().collect(), - parameter.into_iter().collect(), - ); - self.tabs[i].scene.bump_constraints_epoch(); - if self.tabs[i].scene.selected_constraint == Some(id) { - self.tabs[i].scene.selected_constraint = None; - } - let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched - .into_iter() - .map(|h| (h, crate::scene::ChangeKind::Modified)) - .collect(); - self.tabs[i].scene.bump_entities(&changes); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - self.refresh_properties(); - true - } - - /// Removes and re-solves the first conflicting constraint in the active scope. - pub(super) fn resolve_one_parametric_conflict(&mut self) { - let i = self.active_tab; - let scope = self.tabs[i].current_parametric_scope(); - let Some(id) = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .and_then(|set| set.conflicts.first().map(|(id, _)| *id)) - else { - return; - }; - self.remove_parametric_constraint(id, "Remove conflicting constraint"); - } - - /// Removes one user-selected constraint from the active standard graph scope. - pub(super) fn delete_parametric_constraint( - &mut self, - id: crate::scene::parametric_constraints::ConstraintId, - ) { - self.remove_parametric_constraint(id, "Delete constraint"); - } - - /// Rebuilds the active tab's `Scene::named_parameters` from the - /// editor's working buffer and re-solves dependent constraints. - pub(super) fn apply_named_parameter_editor_rows(&mut self) { - let i = self.active_tab; - // A name shared by more than one row can't be resolved by picking - // one arbitrarily (`ParameterTable::set` would just let the last - // one silently win) -- refuse every row sharing that name up front, - // same as `named_parameters::preview`'s live check. - let duplicates = crate::ui::window::named_parameters::duplicate_name_rows( - &self.named_parameter_editor_rows, - ); - let mut table = crate::scene::named_parameters::ParameterTable::new(); - let mut failed = 0usize; - for (idx, row) in self.named_parameter_editor_rows.iter().enumerate() { - let name = row.name.trim(); - if name.is_empty() || duplicates.contains(&idx) { - continue; - } - if let Err(e) = table.set(name, row.formula.trim()) { - self.command_line - .push_error(crate::tf!("Parameter '{}': {}", name, e).as_ref()); - failed += 1; - } - } - if !duplicates.is_empty() { - self.command_line.push_error( - crate::tf!( - "{} row(s) skipped: duplicate parameter name.", - duplicates.len() - ) - .as_ref(), - ); - failed += duplicates.len(); - } - let param_count = table.len(); - - // The whole table just changed, not one isolated value, so there is - // no cheaper "which handles actually moved" test worth doing here — - // re-solve every scope with a constraint driven by *any* named - // parameter (mirrors `solve_scope`'s own "full rebuild, not - // incremental" philosophy). - let touched: Vec = self.tabs[i] - .scene - .parametric_constraints - .iter() - .flat_map(|set| set.constraints.iter()) - .filter(|c| { - c.enabled - && matches!( - c.driving_param, - Some(crate::scene::named_parameters::DrivingValue::Named(_)) - ) - }) - .flat_map(|c| c.refs.iter().map(|r| r.entity)) - .collect(); - - // A changed value moves a dimensional constraint's second point; its - // first point stays, as in the reference. - let anchors: Vec = self.tabs[i] - .scene - .parametric_constraints - .iter() - .flat_map(|set| set.constraints.iter()) - .filter(|c| { - c.enabled - && matches!( - c.driving_param, - Some(crate::scene::named_parameters::DrivingValue::Named(_)) - ) - }) - .flat_map(|c| { - crate::scene::parametric_constraints::dimensional_anchor_refs( - &self.tabs[i].scene.document, - &c.refs, - ) - }) - .collect(); - let pending = self.begin_undo(i, "Apply named parameters", touched.len(), true); - self.tabs[i].scene.record_undo_named_parameters_before(); - self.tabs[i].scene.named_parameters = table; - self.tabs[i].dirty = true; - if !touched.is_empty() { - let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched - .into_iter() - .map(|h| (h, crate::scene::ChangeKind::Modified)) - .collect(); - self.tabs[i].scene.bump_entities_with_parametric_policy( - &changes, - &anchors, - self.constraint_solve_mode, - ); - } else { - self.tabs[i].scene.sync_native_parametric_graph(); - } - self.tabs[i].scene.refresh_dynamic_dimension_texts(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - if failed == 0 { - self.command_line - .push_info(crate::tf!("{} parameter(s) applied.", param_count).as_ref()); - } - } - - /// Re-solves every entity touched by an enabled constraint driven by - /// `name` — the targeted, single-row counterpart to - /// `apply_named_parameter_editor_rows`'s "re-solve what it affects": an - /// inline Properties-panel edit changes one parameter, not the whole - /// table, so unlike that whole-table Apply this can cheaply scope the - /// re-solve to just the entities that actually reference it. - fn resolve_named_parameter_edit(&mut self, i: usize, name: &str) { - let readers: Vec<&crate::scene::parametric_constraints::ParametricConstraint> = self - .tabs[i] - .scene - .parametric_constraints - .iter() - .flat_map(|set| set.constraints.iter()) - .filter(|c| { - c.enabled - && matches!(&c.driving_param, Some(crate::scene::named_parameters::DrivingValue::Named(n)) if n == name) - }) - .collect(); - let touched: Vec = readers - .iter() - .flat_map(|c| c.refs.iter().map(|r| r.entity)) - .collect(); - // A changed value moves a dimensional constraint's second point; its - // first point (and the line it measures perpendicular to) stays, as - // in the reference. - let anchors: Vec = readers - .iter() - .flat_map(|c| { - crate::scene::parametric_constraints::dimensional_anchor_refs( - &self.tabs[i].scene.document, - &c.refs, - ) - }) - .collect(); - self.tabs[i].dirty = true; - if touched.is_empty() { - self.tabs[i].scene.sync_native_parametric_graph(); - return; - } - let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched - .into_iter() - .map(|h| (h, crate::scene::ChangeKind::Modified)) - .collect(); - self.tabs[i] - .scene - .bump_entities_with_parametric_policy(&changes, &anchors, false); - } - - /// Commits one field of one Parameters-section row (Properties panel) — - /// the per-row, commit-on-submit counterpart to - /// `apply_named_parameter_editor_rows`'s whole-table Apply. A Formula - /// edit is a plain redefine (`ParameterTable::set` on the existing - /// name); a Name edit is a remove-then-set (there's no rename primitive - /// — `ParameterTable` upserts by name), restoring the old name if the - /// new one fails to validate so nothing is silently lost. Either way - /// this only re-solves what actually depends on this one parameter — - /// no "duplicate name" scan across the whole table is needed the way - /// the buffered modal Apply needs one, since every row here is always - /// already a real, distinct table entry. - pub(super) fn on_prop_param_commit( - &mut self, - index: usize, - field: crate::ui::window::named_parameters::ParamField, - ) -> Task { - use crate::ui::properties::FieldKey; - use crate::ui::window::named_parameters::ParamField; - let i = self.active_tab; - let key = FieldKey::Param(index, field); - self.tabs[i].properties.active_field = None; - let Some(typed) = self.tabs[i].properties.edit_buf.remove(&key) else { - return Task::none(); - }; - let typed = typed.trim().to_string(); - let Some(current) = self.tabs[i] - .scene - .named_parameters() - .iter() - .nth(index) - .cloned() - else { - self.refresh_properties(); - return Task::none(); - }; - - match field { - ParamField::Formula if typed.is_empty() || typed == current.source => { - self.refresh_properties(); - return Task::none(); - } - ParamField::Name if typed.is_empty() || typed == current.name => { - self.refresh_properties(); - return Task::none(); - } - ParamField::Name if self.tabs[i].scene.named_parameters().contains(&typed) => { - self.command_line - .push_error(crate::tf!("Parameter '{}' already exists.", typed).as_ref()); - self.refresh_properties(); - return Task::none(); - } - _ => {} - } - - let pending = self.begin_undo(i, "Edit named parameter", 0, true); - self.tabs[i].scene.record_undo_named_parameters_before(); - - let (result, resolve_name) = match field { - ParamField::Formula => ( - self.tabs[i] - .scene - .named_parameters_mut() - .set(¤t.name, &typed), - current.name.clone(), - ), - ParamField::Name => { - let description = self.tabs[i] - .scene - .named_parameters() - .description(¤t.name) - .to_string(); - self.tabs[i] - .scene - .named_parameters_mut() - .remove(¤t.name); - let outcome = self.tabs[i] - .scene - .named_parameters_mut() - .set(&typed, ¤t.source); - let kept = if outcome.is_err() { - let _ = self.tabs[i] - .scene - .named_parameters_mut() - .set(¤t.name, ¤t.source); - current.name.as_str() - } else { - typed.as_str() - }; - self.tabs[i] - .scene - .named_parameters_mut() - .set_description(kept, &description); - (outcome, typed.clone()) - } - }; - - if let Err(e) = result { - self.tabs[i].scene.take_undo_recording(); - self.command_line - .push_error(crate::tf!("Parameter '{}': {}", current.name, e).as_ref()); - self.refresh_properties(); - return Task::none(); - } - - if field == ParamField::Name { - let affected: Vec = self.tabs[i] - .scene - .parametric_constraints - .iter() - .enumerate() - .filter_map(|(index, set)| { - set.constraints - .iter() - .any(|constraint| { - matches!(&constraint.driving_param, Some(crate::scene::named_parameters::DrivingValue::Named(name)) if name == ¤t.name) - }) - .then_some(index) - }) - .collect(); - for set_index in affected { - let scope = self.tabs[i].scene.parametric_constraints[set_index].scope; - let before = self.tabs[i].scene.parametric_constraints[set_index].clone(); - self.tabs[i] - .scene - .record_undo_parametric_constraints_before(scope, before); - // A parameter rename rewrites the `Named` driving references - // pointing at it — the glyph labels change with them. - let mut renamed = false; - for constraint in - &mut self.tabs[i].scene.parametric_constraints[set_index].constraints - { - if let Some(crate::scene::named_parameters::DrivingValue::Named(name)) = - &mut constraint.driving_param - { - if name == ¤t.name { - *name = typed.clone(); - renamed = true; - } - } - } - if renamed { - self.tabs[i].scene.bump_constraints_epoch(); - } - } - } - self.resolve_named_parameter_edit(i, &resolve_name); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - self.refresh_properties(); - Task::none() - } - - /// The dynamic dimension the Properties panel targets: its handle, scope, - /// constraint id and parameter name. - fn dynamic_dimension_target( - &self, - i: usize, - ) -> Option<( - Handle, - crate::scene::parametric_constraints::ParametricScope, - crate::scene::parametric_constraints::ConstraintId, - String, - )> { - use crate::scene::named_parameters::DrivingValue; - use crate::scene::parametric_constraints::dynamic_dimension_constraint; - let handle = *self.property_target_handles(i).first()?; - let (set, constraint) = - dynamic_dimension_constraint(&self.tabs[i].scene.parametric_constraints, handle)?; - let Some(DrivingValue::Named(name)) = &constraint.driving_param else { - return None; - }; - Some((handle, set.scope, constraint.id, name.clone())) - } - - /// Commits a dynamic dimension's Description row into its parameter. - pub(super) fn on_dynamic_dimension_description_commit( - &mut self, - field: &'static str, - ) -> Task { - use crate::ui::properties::FieldKey; - let i = self.active_tab; - self.tabs[i].properties.active_field = None; - let Some(typed) = self.tabs[i] - .properties - .edit_buf - .remove(&FieldKey::Geom(field)) - else { - return Task::none(); - }; - let Some((_, _, _, name)) = self.dynamic_dimension_target(i) else { - self.refresh_properties(); - return Task::none(); - }; - let pending = self.begin_undo(i, "Constraint description", 0, true); - self.tabs[i].scene.record_undo_named_parameters_before(); - self.tabs[i] - .scene - .named_parameters_mut() - .set_description(&name, &typed); - self.tabs[i].dirty = true; - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - self.refresh_properties(); - Task::none() - } - - /// Applies a dynamic dimension's Constraint Form or Reference choice. - pub(super) fn on_dynamic_dimension_choice( - &mut self, - field: &'static str, - value: &str, - ) -> Task { - use crate::scene::parametric_constraints::DYNAMIC_DIMENSION_LAYER; - let i = self.active_tab; - let Some((handle, scope, id, _)) = self.dynamic_dimension_target(i) else { - return Task::none(); - }; - match field { - "dyn_constraint_form" => { - // Annotational: an ordinary dimension on the current layer - // that plots; Dynamic: the gray one on the constraints layer. - let annotational = value.eq_ignore_ascii_case("Annotational"); - let active_layer = self.tabs[i].active_layer.clone(); - if !annotational { - self.tabs[i].scene.ensure_dynamic_dimension_layer(); - } - self.apply_property_op(i, "Constraint form", &[handle], |app, handle| { - use crate::entities::dim_override as dov; - let Some(mut entity) = app.tabs[i].scene.document.get_entity(handle).cloned() - else { - return; - }; - if annotational { - entity.as_entity_mut().set_layer(active_layer.clone()); - entity.common_mut().color = acadrust::types::Color::ByLayer; - // The dynamic form's screen-size and horizontal-text - // overrides go; the style draws it. - for code in [ - dov::DIMSCALE, - dov::DIMGAP, - dov::DIMEXO, - dov::DIMEXE, - dov::DIMASZ, - dov::DIMTAD, - dov::DIMTIH, - dov::DIMTOH, - ] { - dov::set_on_entity(&mut entity, code, None); - } - } else { - entity - .as_entity_mut() - .set_layer(DYNAMIC_DIMENSION_LAYER.to_string()); - entity.common_mut().color = acadrust::types::Color::Rgb { - r: 103, - g: 109, - b: 118, - }; - } - app.tabs[i].scene.update_entity(entity); - }); - // The dynamic form is rescaled to the screen and shows the - // trimmed value; the annotational one is never hidden. - self.tabs[i].scene.refresh_dynamic_dimension_scales(true); - self.tabs[i].scene.refresh_dynamic_dimension_texts(); - self.tabs[i].scene.refresh_hidden_dynamic_dimensions(); - } - "dyn_constraint_reference" => { - // A reference constraint reads the geometry instead of - // driving it; its text sits in parentheses. - let reference = - value.eq_ignore_ascii_case("Yes") || value == crate::t!("Yes").as_ref(); - let Some(before) = self.tabs[i].scene.parametric_constraint_set(scope).cloned() - else { - return Task::none(); - }; - let pending = self.begin_undo(i, "Constraint reference", 0, true); - self.tabs[i] - .scene - .record_undo_parametric_constraints_before(scope, before); - self.tabs[i].scene.record_undo_named_parameters_before(); - let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); - if let Some(constraint) = set.constraints.iter_mut().find(|c| c.id == id) { - constraint.enabled = !reference; - } - // Toggling `enabled` changes the glyph placement set (and the - // dynamic pills), so the memoised placements must miss next - // frame — same guarantee `set_constraint_enabled` gives. - self.tabs[i].scene.bump_constraints_epoch(); - self.tabs[i].scene.refresh_dynamic_dimension_texts(); - self.tabs[i].scene.sync_native_parametric_graph(); - self.tabs[i].dirty = true; - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - self.refresh_properties(); - } - _ => {} - } - Task::none() - } - - /// Commits a dynamic dimension's Name or Expression row: the typed text - /// goes to the Parameters-section row of the constraint's parameter. - pub(super) fn on_dynamic_dimension_field_commit( - &mut self, - field: &'static str, - param_field: crate::ui::window::named_parameters::ParamField, - ) -> Task { - use crate::scene::named_parameters::DrivingValue; - use crate::scene::parametric_constraints::dynamic_dimension_constraint; - use crate::ui::properties::FieldKey; - let i = self.active_tab; - self.tabs[i].properties.active_field = None; - let Some(typed) = self.tabs[i] - .properties - .edit_buf - .remove(&FieldKey::Geom(field)) - else { - return Task::none(); - }; - let handles = self.property_target_handles(i); - let name = handles.first().and_then(|handle| { - let (_, constraint) = - dynamic_dimension_constraint(&self.tabs[i].scene.parametric_constraints, *handle)?; - match &constraint.driving_param { - Some(DrivingValue::Named(name)) => Some(name.clone()), - _ => None, - } - }); - let index = name.and_then(|name| { - self.tabs[i] - .scene - .named_parameters() - .iter() - .position(|parameter| parameter.name == name) - }); - let Some(index) = index else { - self.refresh_properties(); - return Task::none(); - }; - self.tabs[i] - .properties - .edit_buf - .insert(FieldKey::Param(index, param_field), typed); - self.on_prop_param_commit(index, param_field) - } - - /// Removes Parameters-section row `index` immediately. Any - /// constraint that referenced it gets the same "undefined reference" - /// resolve-failure treatment a formula's own bad reference already gets - /// (`build_constraint`) — re-solving surfaces that rather than needing - /// special-case handling here. - pub(super) fn on_prop_param_delete(&mut self, index: usize) -> Task { - let i = self.active_tab; - let Some(name) = self.tabs[i] - .scene - .named_parameters() - .iter() - .nth(index) - .map(|p| p.name.clone()) - else { - return Task::none(); - }; - let pending = self.begin_undo(i, "Delete named parameter", 0, true); - self.tabs[i].scene.record_undo_named_parameters_before(); - self.tabs[i].scene.named_parameters_mut().remove(&name); - self.resolve_named_parameter_edit(i, &name); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - self.refresh_properties(); - Task::none() - } - - /// The Parameters section's "+ Add parameter" row: appends a fresh, - /// uniquely-named parameter (`param1`, `param2`, …) the user then - /// renames/redefines inline via `on_prop_param_commit`. Defaults to `1`, - /// not `0`: renaming this onto a name an existing Distance/Radius - /// constraint already references (unusual, but reachable — define - /// first, wire up the constraint's reference later) would otherwise - /// briefly collapse that geometry to a zero-length/zero-radius - /// degenerate state, which is a genuine numerical singularity for a - /// point-to-point distance constraint to grow back out of (no defined - /// direction to separate two exactly-coincident points from). `1` never - /// hits that. - pub(super) fn on_prop_param_add_new(&mut self) -> Task { - let i = self.active_tab; - let mut n = 1usize; - let name = loop { - let candidate = format!("param{n}"); - if !self.tabs[i].scene.named_parameters().contains(&candidate) { - break candidate; - } - n += 1; - }; - let pending = self.begin_undo(i, "Add named parameter", 0, true); - self.tabs[i].scene.record_undo_named_parameters_before(); - let _ = self.tabs[i].scene.named_parameters_mut().set(&name, "1"); - self.tabs[i].scene.sync_native_parametric_graph(); - self.tabs[i].dirty = true; - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - self.refresh_properties(); - Task::none() - } - - /// Bring back the command a transparent zoom parked, once the zoom is - /// over (no command active any more). No-op otherwise. - pub(in crate::app) fn resume_transparent_parent(&mut self, i: usize) { - if !self.tabs[i].transparent_resume { - return; - } - if self.tabs[i].active_cmd.is_some() { - // The transparent prompt is still up (or the parent was already - // restored by a Cancel). - if self.tabs[i].suspended_cmd.is_none() { - self.tabs[i].transparent_resume = false; - } - return; - } - self.tabs[i].transparent_resume = false; - let Some(parent) = self.tabs[i].suspended_cmd.take() else { - return; - }; - self.tabs[i].active_cmd = Some(parent); - let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); - if let Some(p) = prompt { - self.command_line.push_info(&p); - } - let opts = self.tabs[i] - .active_cmd - .as_ref() - .map(|c| c.options()) - .unwrap_or_default(); - self.command_line.set_step_options(opts); - self.refresh_active_cmd_preview(i); - } - - pub(in crate::app) fn start_mtp_modifier(&mut self, i: usize) { - let parent = self.tabs[i].active_cmd.take(); - self.tabs[i].suspended_cmd = parent; - self.tabs[i].scene.clear_preview_wire(); - let cmd = crate::command::Mid2PointCommand::new(); - let prompt = crate::command::CadCommand::prompt(&cmd); - self.tabs[i].active_cmd = Some(Box::new(cmd)); - self.command_line.push_info(&prompt); - self.command_line.set_step_options(Vec::new()); - self.refresh_active_cmd_preview(i); - } - - /// Apply a command replacement without deleting a one-to-one entity edit. - /// Splits and entity-type conversions still allocate replacement identities. - fn replace_command_entity( - &mut self, - tab: usize, - handle: Handle, - mut entities: Vec, - ) -> Vec { - let same_type = entities.len() == 1 - && self.tabs[tab] - .scene - .document - .get_entity(handle) - .is_some_and(|original| { - std::mem::discriminant(original) == std::mem::discriminant(&entities[0]) - }); - let handles = if same_type { - let mut entity = entities.pop().expect("one replacement"); - entity.common_mut().handle = handle; - // Scene::update_entity also retains the live owning block and - // refreshes dependent render caches without erase notifications. - if self.tabs[tab].scene.update_entity(entity) { - vec![handle] - } else { - Vec::new() - } - } else { - let owner = self.tabs[tab] - .scene - .document - .get_entity(handle) - .map(|entity| entity.common().owner_handle); - self.tabs[tab].scene.erase_entities(&[handle]); - entities - .into_iter() - .map(|mut entity| { - entity.common_mut().handle = Handle::NULL; - if let Some(owner) = owner { - entity.common_mut().owner_handle = owner; - } - self.tabs[tab].scene.add_entity(entity) - }) - .collect() - }; - for &updated in &handles { - if matches!( - self.tabs[tab].scene.document.get_entity(updated), - Some(acadrust::EntityType::Dimension(_)) - ) { - self.tabs[tab].scene.invalidate_dim_block_recorded(updated); - } - } - handles - } - - fn apply_pedit_result( - &mut self, - tab: usize, - handle: Handle, - op: crate::modules::draw::modify::pedit::PeditOp, - ) -> Task { - use crate::modules::draw::modify::pedit::{apply_pedit, convert_to_polyline, PeditOp}; - - if !matches!( - &op, - PeditOp::Multiple(_, _) | PeditOp::JoinSelection(_, _, _) - ) && self.reject_locked_edit(tab, handle) - { - return Task::none(); - } - match &op { - PeditOp::JoinSelection(handles, fuzz, kind) => { - let mut available = handles - .iter() - .filter_map(|handle| { - if self.tabs[tab].scene.is_layer_locked(*handle) { - return None; - } - self.tabs[tab] - .scene - .document - .get_entity(*handle) - .cloned() - .map(|entity| (*handle, entity)) - }) - .collect::>(); - let connector_distance = - crate::modules::draw::modify::pedit::selection_connector_distance( - &available, *fuzz, - ); - let mut changes = Vec::new(); - while !available.is_empty() { - let (source_handle, source) = available.remove(0); - let candidates = available - .iter() - .map(|(handle, entity)| (*handle, entity)) - .collect::>(); - if let Some((mut result, consumed)) = - crate::modules::draw::modify::pedit::join_selection_extend( - &source, - &candidates, - *fuzz, - *kind, - connector_distance, - ) - { - *result.common_mut() = source.common().clone(); - available.retain(|(handle, _)| !consumed.contains(handle)); - changes.push((source_handle, result, consumed)); - } - } - if !changes.is_empty() { - self.push_undo_snapshot(tab, "PEDIT"); - for (source, replacement, consumed) in changes { - self.tabs[tab].scene.erase_entities(&consumed); - self.tabs[tab].scene.update_entity(replacement.clone()); - if let Some(command) = self.tabs[tab].active_cmd.as_mut() { - for old in consumed { - command.on_entity_replaced(old, &[source]); - } - command.inject_picked_entity(replacement); - } - } - if let Some(command) = self.tabs[tab].active_cmd.as_mut() { - command.on_pedit_applied(); - } - self.tabs[tab].dirty = true; - self.refresh_properties(); - } - } - PeditOp::Multiple(handles, operation) => { - let replacements: Vec<_> = handles - .iter() - .filter(|handle| !self.tabs[tab].scene.is_layer_locked(**handle)) - .filter_map(|handle| { - let original = self.tabs[tab].scene.document.get_entity(*handle)?; - if matches!(operation.as_ref(), PeditOp::ConvertToPolyline) { - convert_to_polyline(original) - .map(|replacement| (*handle, replacement, true)) - } else { - let mut replacement = original.clone(); - apply_pedit(&mut replacement, operation).then_some(( - *handle, - replacement, - false, - )) - } - }) - .collect(); - if replacements.is_empty() { - return Task::none(); - } - self.push_undo_snapshot(tab, "PEDIT"); - for (handle, replacement, converted) in replacements { - let updated_handle = if converted { - self.tabs[tab].scene.erase_entities(&[handle]); - let new_handle = self.tabs[tab].scene.add_entity(replacement); - if let Some(command) = self.tabs[tab].active_cmd.as_mut() { - command.on_entity_replaced(handle, &[new_handle]); - } - new_handle - } else { - if let Some(entity) = self.tabs[tab].scene.document.get_entity_mut(handle) { - *entity = replacement; - } - self.tabs[tab].scene.refresh_fill_model(handle); - self.tabs[tab] - .scene - .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); - handle - }; - let updated = self.tabs[tab] - .scene - .document - .get_entity(updated_handle) - .cloned(); - if let (Some(command), Some(entity)) = - (self.tabs[tab].active_cmd.as_mut(), updated) - { - command.inject_picked_entity(entity); - } - } - if let Some(command) = self.tabs[tab].active_cmd.as_mut() { - command.on_pedit_applied(); - } - self.tabs[tab].dirty = true; - self.refresh_properties(); - } - PeditOp::VertexRange { first, last, split } => { - let pieces = self.tabs[tab] - .scene - .document - .get_entity(handle) - .and_then(|entity| { - crate::modules::draw::modify::pedit::edit_vertex_range( - entity, *first, *last, *split, - ) - }); - if let Some(mut pieces) = pieces { - self.push_undo_snapshot(tab, "PEDIT"); - let source = pieces.remove(0); - self.tabs[tab].scene.update_entity(source); - for mut piece in pieces { - piece.common_mut().handle = Handle::NULL; - self.tabs[tab].scene.add_entity(piece); - } - let updated = self.tabs[tab].scene.document.get_entity(handle).cloned(); - if let Some(command) = self.tabs[tab].active_cmd.as_mut() { - if let Some(entity) = updated { - command.inject_picked_entity(entity); - } - command.on_pedit_applied(); - } - self.tabs[tab].dirty = true; - self.refresh_properties(); - } else { - self.command_line.push_error("PEDIT: invalid vertex range."); - } - } - PeditOp::ConvertToPolyline => { - let converted = self.tabs[tab] - .scene - .document - .get_entity(handle) - .and_then(convert_to_polyline); - match converted { - Some(polyline) => { - return self - .apply_cmd_result(CmdResult::ReplaceEntity(handle, vec![polyline])); - } - None => self - .command_line - .push_error(crate::t!("PEDIT: cannot convert this entity.").as_ref()), - } - } - _ => { - self.push_undo_snapshot(tab, "PEDIT"); - let changed = self.tabs[tab] - .scene - .document - .get_entity_mut(handle) - .map(|entity| apply_pedit(entity, &op)) - .unwrap_or(false); - if changed { - let updated = self.tabs[tab].scene.document.get_entity(handle).cloned(); - if let Some(command) = self.tabs[tab].active_cmd.as_mut() { - if let Some(entity) = updated { - command.inject_picked_entity(entity); - } - command.on_pedit_applied(); - } - self.tabs[tab].dirty = true; - self.tabs[tab].scene.refresh_fill_model(handle); - self.tabs[tab] - .scene - .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); - self.command_line - .push_output(crate::t!("PEDIT: applied.").as_ref()); - self.refresh_properties(); - } else { - self.discard_last_undo_entry(tab); - self.command_line.push_error( - crate::t!("PEDIT: operation not applicable to this entity.").as_ref(), - ); - } - } - } - if let Some(prompt) = self.tabs[tab] - .active_cmd - .as_ref() - .map(|command| command.prompt()) - { - self.command_line.push_info(&prompt); - } - Task::none() - } - - fn apply_continuous_constraints(&mut self, i: usize, new_handles: &[acadrust::Handle]) { - let supported_creation = self.tabs[i] - .active_cmd - .as_ref() - .is_some_and(|command| { - matches!( - command.name(), - "LINE" - | "PLINE" - | "POLYLINE" - | "RECTANG" - | "RECTANGLE" - | "POLYGON" - | "CIRCLE" - | "ARC" - ) - }); - if supported_creation { - self.apply_inferred_constraints(i, new_handles); - } - } - - pub(in crate::app) fn apply_inferred_constraints( - &mut self, - i: usize, - changed_handles: &[acadrust::Handle], - ) { - if !self.constraint_infer || changed_handles.is_empty() { - return; - } - let scope = self.tabs[i].current_parametric_scope(); - let owner = scope.owner_handle(&self.tabs[i].scene.document); - let candidates = self.tabs[i] - .scene - .document - .block_records - .iter() - .find(|record| record.handle == owner) - .map(|record| record.entity_handles.clone()) - .unwrap_or_default(); - let inferred = self.tabs[i].scene.inferred_parametric_constraints( - scope, - &candidates, - &self.auto_constrain_settings, - ); - let inferred: Vec<_> = inferred - .into_iter() - .filter(|(_, refs)| { - refs.iter() - .any(|reference| changed_handles.contains(&reference.entity)) - }) - .collect(); - if inferred.is_empty() { - return; - } - let before = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .cloned() - .unwrap_or_else(|| { - crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) - }); - self.tabs[i] - .scene - .record_undo_parametric_constraints_before(scope, before); - let mut touched = Vec::new(); - for (kind, refs) in inferred { - touched.extend(refs.iter().map(|reference| reference.entity)); - let id = self.tabs[i] - .scene - .parametric_constraint_set_mut(scope) - .add(kind, refs, None); - self.tabs[i].scene.note_parametric_constraint_applied( - scope, - id, - self.constraint_bar_display, - ); - } - touched.sort_unstable_by_key(|handle| handle.value()); - touched.dedup(); - let changes: Vec<_> = touched - .into_iter() - .map(|handle| (handle, crate::scene::ChangeKind::Modified)) - .collect(); - self.tabs[i].scene.bump_entities_with_parametric_policy( - &changes, - &[], - self.constraint_solve_mode, - ); - } - - fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task { - let i = self.active_tab; - if let Some(bind) = self.tabs[i] - .active_cmd - .as_ref() - .and_then(|command| command.nested_copy_bind_setting()) - { - if self.ncopy_bind != bind { - self.ncopy_bind = bind; - self.persist_settings_if_changed(); - } - } - let preserve_commit_style = self.tabs[i] - .active_cmd - .as_ref() - .is_some_and(|command| command.preserve_commit_style()); - let preserve_commit_layer = self.tabs[i] - .active_cmd - .as_ref() - .is_some_and(|command| command.preserve_commit_layer()); - // Task produced by a command the arm dispatches; it must reach the - // runtime or messages such as a chosen render mode are dropped. - let mut dispatched = Task::none(); - match result { - CmdResult::OpenAutoConstrainSettings => { - self.auto_constrain_saved = Some(self.auto_constrain_settings.clone()); - self.auto_constrain_selected_row = 0; - self.auto_constrain_distance_input = - format!("{}", self.auto_constrain_settings.distance_tolerance); - self.auto_constrain_angle_input = - format!("{}", self.auto_constrain_settings.angle_tolerance_deg); - self.active_modal = Some(super::ModalKind::AutoConstrainSettings); - } - CmdResult::NeedPoint => { - // ATTEDIT finished its entity pick: hand the chosen block off to - // the attribute editor dialog and end the command (open_attribute - // _editor reports "no attributes" / "select a block" as needed). - let attedit_handle = self.tabs[i] - .active_cmd - .as_ref() - .and_then(|c| c.attedit_pending_handle()); - if let Some(ins_handle) = attedit_handle { - self.tabs[i].active_cmd = None; - self.command_line.set_step_options(Vec::new()); - self.open_attribute_editor(ins_handle); - return Task::none(); - } - let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); - if let Some(p) = prompt { - self.command_line.push_info(&p); - } - let opts = self.tabs[i] - .active_cmd - .as_ref() - .map(|c| c.options()) - .unwrap_or_default(); - self.command_line.set_step_options(opts); - if !self.tabs[i] - .active_cmd - .as_ref() - .is_some_and(|c| c.entity_pick_highlights_hover()) - { - self.tabs[i].scene.set_hover_highlight(None); - } - // The command may have advanced to a step with a different - // dynamic-input shape (e.g. FILLET object-pick → radius entry). - // Rebuild the fields now so the matching box appears immediately - // and typed digits land in it rather than the command line, - // instead of waiting for the next cursor move to resync. - self.sync_dyn_fields(); - self.refresh_area_preview(i); - } - CmdResult::Preview(wire) => { - self.tabs[i].scene.set_preview_wires(vec![wire]); - let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); - if let Some(p) = prompt { - self.command_line.push_info(&p); - } - } - CmdResult::InterimWire(wire) => { - self.tabs[i].scene.set_interim_wire(wire); - let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); - if let Some(p) = prompt { - self.command_line.push_info(&p); - } - } - CmdResult::CommitEntity(entity) => { - let source_handle = entity.common().handle; - if !source_handle.is_null() - && self.tabs[i] - .scene - .document - .get_entity(source_handle) - .is_some() - && self.reject_locked_edit(i, source_handle) - { - return Task::none(); - } - // A line/arc drawn by a repeating command advances the ARC_CONT - // continuation anchor, so ending one run and launching another - // keeps continuing from the last segment (mirrors the - // CommitAndExit arm). Non-continuable repeats (point/ray) - // leave the anchor untouched. (#327) - if matches!( - entity, - acadrust::EntityType::Line(_) - | acadrust::EntityType::Arc(_) - | acadrust::EntityType::LwPolyline(_) - | acadrust::EntityType::Polyline2D(_) - ) { - self.update_cont_anchor(&entity); - } - let label = self.history_label_from_active_cmd(i, "ENTITY"); - // Ordinary drawables, viewports and raster images use targeted - // entity/object deltas. Block sentinels and novel layers retain - // the structure snapshot fallback. - let delta_safe = self.delta_add_safe(i, &entity); - let pending = self.begin_undo(i, label, 1, delta_safe); - let is_associative_dimension = matches!( - entity, - acadrust::EntityType::Dimension( - acadrust::entities::Dimension::Linear(_) - | acadrust::entities::Dimension::Aligned(_) - ) - ); - let association_enabled = - self.tabs[i].scene.document.header.dimension_associativity == 2; - let committed = if preserve_commit_layer { - self.commit_entity_handle_preserve_layer(entity) - } else { - self.commit_entity_handle(entity) - }; - if is_associative_dimension && association_enabled { - if let Some(handle) = committed { - let sources = self.infer_dimension_sources_guarded(i, handle); - self.tabs[i] - .scene - .attach_dimension_association(handle, sources); - } - } - if let Some(handle) = committed { - self.apply_continuous_constraints(i, &[handle]); - } - self.tabs[i].dirty = true; - let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); - if let Some(p) = prompt { - self.command_line.push_info(&p); - } - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - CmdResult::CommitEntities(mut entities) => { - let locked_source = entities.iter().find_map(|entity| { - let handle = entity.common().handle; - (!handle.is_null() - && self.tabs[i].scene.document.get_entity(handle).is_some() - && self.tabs[i].scene.is_layer_locked(handle)) - .then_some(handle) - }); - if let Some(handle) = locked_source { - self.reject_locked_edit(i, handle); - return Task::none(); - } - let label = self.history_label_from_active_cmd(i, "ENTITY"); - let symbol_names = self.tabs[i] - .active_cmd - .as_ref() - .and_then(|command| command.nested_copy_symbol_names()) - .cloned(); - let delta_safe = symbol_names.is_none() - && entities.iter().all(|entity| self.delta_add_safe(i, entity)); - let pending = self.begin_undo(i, label, entities.len(), delta_safe); - if let Some(names) = symbol_names { - let retained = self.tabs[i] - .scene - .document - .localize_nested_copy_symbols(&mut entities, &names); - if retained > 0 { - self.command_line.push_info(&format!("{} copied object(s) retain imported style references whose dependencies cannot be localized.", retained)); - } - self.refresh_layer_panel(); - } - let mut committed = Vec::new(); - for entity in entities { - let handle = if preserve_commit_style { - self.commit_entity_handle_preserve_style(entity) - } else if preserve_commit_layer { - self.commit_entity_handle_preserve_layer(entity) - } else { - self.commit_entity_handle(entity) - }; - committed.extend(handle); - } - self.apply_continuous_constraints(i, &committed); - self.tabs[i].dirty = true; - self.tabs[i].scene.clear_preview_wire(); - let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); - if let Some(p) = prompt { - self.command_line.push_info(&p); - } - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - CmdResult::CommitEntitiesAndExit(mut entities) => { - let label = self.history_label_from_active_cmd(i, "ENTITY"); - let symbol_names = self.tabs[i] - .active_cmd - .as_ref() - .and_then(|command| command.nested_copy_symbol_names()) - .cloned(); - let delta_safe = symbol_names.is_none() - && entities.iter().all(|entity| self.delta_add_safe(i, entity)); - let pending = self.begin_undo(i, label, entities.len(), delta_safe); - if let Some(names) = symbol_names { - let retained = self.tabs[i] - .scene - .document - .localize_nested_copy_symbols(&mut entities, &names); - if retained > 0 { - self.command_line.push_info(&format!("{} copied object(s) retain imported style references whose dependencies cannot be localized.", retained)); - } - self.refresh_layer_panel(); - } - let mut committed = Vec::new(); - for entity in entities { - let handle = if preserve_commit_style { - self.commit_entity_handle_preserve_style(entity) - } else if preserve_commit_layer { - self.commit_entity_handle_preserve_layer(entity) - } else { - self.commit_entity_handle(entity) - }; - committed.extend(handle); - } - self.apply_continuous_constraints(i, &committed); - self.tabs[i].dirty = true; - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - CmdResult::MviewCreate { - viewport, - preserve_view, - } => { - let saved_view = preserve_view.then(|| { - ( - viewport.view_target.clone(), - viewport.view_direction.clone(), - viewport.view_center.clone(), - viewport.view_height, - viewport.custom_scale, - viewport.lens_length, - viewport.twist_angle, - viewport.status.perspective, - ) - }); - let label = self.history_label_from_active_cmd(i, "MVIEW"); - let pending = self.begin_undo(i, label, 1, true); - let handle = self.commit_entity_handle(acadrust::EntityType::Viewport(viewport)); - if let (Some(handle), Some(saved)) = (handle, saved_view) { - if let Some(acadrust::EntityType::Viewport(viewport)) = - self.tabs[i].scene.document.get_entity_mut(handle) - { - viewport.view_target = saved.0; - viewport.view_direction = saved.1; - viewport.view_center = saved.2; - viewport.view_height = saved.3; - viewport.custom_scale = saved.4; - viewport.lens_length = saved.5; - viewport.twist_angle = saved.6; - viewport.status.perspective = saved.7; - } - self.tabs[i].scene.camera_generation += 1; - } - self.tabs[i].dirty = true; - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - if let Some(pending) = pending { - self.commit_undo_delta(i, pending); - } - } - CmdResult::MviewCreateClipped { - boundary, - boundary_handle, - } => { - if boundary.is_none() { - let scene = &self.tabs[i].scene; - let valid = scene.entity_belongs_to_current_layout(boundary_handle) - && scene - .document - .get_entity(boundary_handle) - .is_some_and(|entity| match entity { - acadrust::EntityType::Circle(_) => true, - acadrust::EntityType::Ellipse(ellipse) => ellipse.is_full(), - acadrust::EntityType::LwPolyline(polyline) => polyline.is_closed, - acadrust::EntityType::Polyline(polyline) => polyline.is_closed(), - acadrust::EntityType::Polyline2D(polyline) => polyline.is_closed(), - acadrust::EntityType::Polyline3D(polyline) => polyline.flags.closed, - _ => false, - }); - if !valid { - self.command_line.push_error( - crate::t!("MVIEW Object: select a closed paper-space circle, ellipse, or polyline.").as_ref(), - ); - if let Some(prompt) = self.tabs[i] - .active_cmd - .as_ref() - .map(|command| command.prompt()) - { - self.command_line.push_info(&prompt); - } - return Task::none(); - } - } - - let created_boundary = boundary.is_some(); - let touched = 2; - let label = self.history_label_from_active_cmd(i, "MVIEW"); - let pending = self.begin_undo(i, label, touched, true); - let clip_handle = match boundary { - Some(mut boundary) => { - // A non-rectangular viewport owns a helper boundary - // entity through `clip_boundary_handle`. Keep that - // helper in the document for DWG compatibility and - // stencil clipping, but do not expose it as a separate - // selectable polyline. - boundary.common_mut().invisible = true; - match self.commit_entity_handle(boundary) { - Some(handle) => handle, - None => { - self.tabs[i].active_cmd = None; - if let Some(pending) = pending { - self.commit_undo_delta(i, pending); - } - return Task::none(); - } - } - } - None => boundary_handle, - }; - let polygon = self.tabs[i].scene.clip_boundary_polygon(clip_handle, 0.0); - let bounds: Option<(f64, f64, f64, f64)> = - polygon.iter().fold(None, |bounds, point| { - if !point[0].is_finite() || !point[1].is_finite() { - return bounds; - } - Some(match bounds { - Some((min_x, min_y, max_x, max_y)) => ( - min_x.min(point[0] as f64), - min_y.min(point[1] as f64), - max_x.max(point[0] as f64), - max_y.max(point[1] as f64), - ), - None => ( - point[0] as f64, - point[1] as f64, - point[0] as f64, - point[1] as f64, - ), - }) - }); - let Some((min_x, min_y, max_x, max_y)) = bounds else { - self.command_line.push_error( - crate::t!("MVIEW: the clipping boundary has no usable area.").as_ref(), - ); - self.tabs[i].active_cmd = None; - if let Some(pending) = pending { - self.commit_undo_delta(i, pending); - } - return Task::none(); - }; - if max_x - min_x < 1e-6 || max_y - min_y < 1e-6 { - self.command_line.push_error( - crate::t!("MVIEW: the clipping boundary has no usable area.").as_ref(), - ); - self.tabs[i].active_cmd = None; - if let Some(pending) = pending { - self.commit_undo_delta(i, pending); - } - return Task::none(); - } - - let mut viewport = acadrust::entities::Viewport::new(); - viewport.center = acadrust::types::Vector3::new( - (min_x + max_x) / 2.0, - (min_y + max_y) / 2.0, - 0.0, - ); - viewport.width = max_x - min_x; - viewport.height = max_y - min_y; - viewport.id = 2; - viewport.clip_boundary_handle = clip_handle; - let viewport_handle = - self.commit_entity_handle(acadrust::EntityType::Viewport(viewport)); - if let Some(viewport_handle) = viewport_handle { - if !created_boundary { - let before = self.tabs[i] - .scene - .document - .get_entity(clip_handle) - .cloned() - .map(std::sync::Arc::new); - self.tabs[i].scene.record_undo_before(clip_handle, before); - } - if let Some(boundary) = self.tabs[i].scene.document.get_entity_mut(clip_handle) - { - let common = boundary.common_mut(); - common.invisible = true; - if !common.reactors.contains(&viewport_handle) { - common.reactors.push(viewport_handle); - } - } - self.tabs[i] - .scene - .bump_entities(&[(clip_handle, crate::scene::ChangeKind::Modified)]); - } - self.tabs[i].dirty = true; - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - if let Some(pending) = pending { - self.commit_undo_delta(i, pending); - } - } - CmdResult::WipeoutFromPolyline { - handle, - erase_source, - } => { - let wipeout = { - let scene = &self.tabs[i].scene; - scene - .entity_belongs_to_active_space(handle) - .then(|| scene.document.get_entity(handle)) - .flatten() - .and_then(crate::modules::draw::draw::wipeout::wipeout_from_polyline) - }; - if let Some(wipeout) = wipeout { - if erase_source { - return self.apply_cmd_result(CmdResult::ReplaceMany( - vec![(handle, Vec::new())], - vec![wipeout], - )); - } - return self.apply_cmd_result(CmdResult::CommitAndExit(wipeout)); - } - self.command_line.push_error( - crate::t!("WIPEOUT Polyline: select a straight, closed, planar 2D polyline with at least 3 non-intersecting vertices.").as_ref(), - ); - let command = crate::modules::draw::draw::wipeout::WipeoutCommand::new_polyline(); - self.command_line - .push_info(&crate::command::CadCommand::prompt(&command)); - self.tabs[i].active_cmd = Some(Box::new(command)); - } - CmdResult::MviewSwitchLayout(layout) => { - let task = self.on_layout_switch_preserving_command(layout); - if let Some(prompt) = self.tabs[i] - .active_cmd - .as_ref() - .map(|command| command.prompt()) - { - self.command_line.push_info(&prompt); - } - return task; - } - CmdResult::MviewCancelToLayout(layout) => { - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - return self.on_layout_switch(layout); - } - CmdResult::TransformSelected(mut handles, transform) => { - handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); - if handles.is_empty() { - self.tabs[i].active_cmd = None; - return Task::none(); - } - if matches!(&transform, crate::command::EntityTransform::Translate(_)) { - let scope = self.tabs[i].current_parametric_scope(); - handles = self.tabs[i] - .scene - .parametric_connected_handles(scope, &handles, false); - handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); - } - let label = self.history_label_from_active_cmd(i, "MOVE"); - // A translation carries its complete connected constraint - // component so each member keeps its own dimensions. Other - // transforms retain their selected-object behavior. - let pending = self.begin_undo(i, label, handles.len(), true); - self.tabs[i].scene.transform_entities(&handles, &transform); - self.apply_inferred_constraints(i, &handles); - self.tabs[i].dirty = true; - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - self.refresh_properties(); - if let Some(p) = pending { - self.commit_undo_delta(i, p); - } - } - CmdResult::CopySelected(mut handles, transform) => { - handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); - if handles.is_empty() { - return Task::none(); - } - let label = self.history_label_from_active_cmd(i, "COPY"); - // Copying a dimension clones a *D block record, so gate delta on - // the selection being dimension-free. - let delta_safe = self.delta_copy_safe(i, &handles); - let pending = self.begin_undo(i, label, handles.len(), delta_safe); - let new_handles = self.tabs[i].scene.copy_entities(&handles, &transform); - self.tabs[i].dirty = true; - self.tabs[i].scene.deselect_all(); - for h in new_handles { - self.tabs[i].scene.select_entity(h, false); - } - self.tabs[i].scene.clear_preview_wire(); - let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); - if let Some(p) = prompt { - self.command_line.push_info(&p); - } - self.refresh_properties(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - CmdResult::CopyToClipboard { handles, base } => { - let count = self.copy_entities_to_clipboard(i, &handles, base); - self.command_line - .push_info(crate::tf!("{} object(s) copied to clipboard.", count).as_ref()); - let prompt = self.tabs[i] - .active_cmd - .as_ref() - .map(|command| command.prompt()); - if let Some(prompt) = prompt { - self.command_line.push_info(&prompt); - } - self.command_line.set_step_options( - self.tabs[i] - .active_cmd - .as_ref() - .map(|command| command.options()) - .unwrap_or_default(), - ); - if !self.tabs[i] - .active_cmd - .as_ref() - .is_some_and(|command| command.entity_pick_highlights_hover()) - { - self.tabs[i].scene.set_hover_highlight(None); - } - self.sync_dyn_fields(); - self.refresh_area_preview(i); - } - CmdResult::CommitAndExit(entity) => { - // XATTACH: the definition and its INSERT are created together - // in one undo step. - let xattach_request = self.tabs[i] - .active_cmd - .as_ref() - .filter(|cmd| cmd.name() == "XATTACH") - .and_then(|cmd| cmd.xattach_request()); - if let Some(request) = xattach_request { - self.commit_xref_attach(i, request, entity); - return Task::none(); - } - let insert_block_name = match &entity { - acadrust::EntityType::Insert(ins) => Some(ins.block_name.clone()), - _ => None, - }; - // Record where this draw ended so ARC_CONT can continue from it - // (before `entity` is moved into commit_entity). - self.update_cont_anchor(&entity); - let label = self.history_label_from_active_cmd(i, "ENTITY"); - let delta_safe = self.delta_add_safe(i, &entity); - let pending = self.begin_undo(i, label, 1, delta_safe); - let is_associative_dimension = matches!( - entity, - acadrust::EntityType::Dimension( - acadrust::entities::Dimension::Linear(_) - | acadrust::entities::Dimension::Aligned(_) - ) - ); - let association_enabled = - self.tabs[i].scene.document.header.dimension_associativity == 2; - let committed = self.commit_entity_handle(entity); - if is_associative_dimension && association_enabled { - if let Some(handle) = committed { - let sources = self.infer_dimension_sources_guarded(i, handle); - self.tabs[i] - .scene - .attach_dimension_association(handle, sources); - } - } - if let Some(handle) = committed { - self.apply_continuous_constraints(i, &[handle]); - } - self.tabs[i].dirty = true; - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - if let Some(name) = insert_block_name { - self.record_block_insert(&name); - } - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - CmdResult::CommitDimension { - mut entity, - association, - preserve_base_style, - continue_command, - } => { - let label = self.history_label_from_active_cmd(i, "DIMENSION"); - let association_mode = self.tabs[i].scene.document.header.dimension_associativity; - let single_source_dimension = matches!( - &entity, - acadrust::EntityType::Dimension(acadrust::entities::Dimension::Ordinate(_)) - ); - // A dimension placed on the sheet but measuring model geometry - // through a viewport carries the compensation as a negative - // DIMLFAC override. - if !preserve_base_style { - crate::scene::creation_style::apply_current_creation_styles( - &self.tabs[i].scene.document, - &mut entity, - ); - } - if !self.apply_viewport_dimension_measurement(i, &mut entity) { - return Task::none(); - } - // Projected points cannot use direct paper-space source inference. - let association_allowed = self.dimension_association_allowed(i); - let inherited_dimension = if preserve_base_style { - match &entity { - acadrust::EntityType::Dimension(dimension) => Some(( - dimension.base().common.layer.clone(), - dimension.base().style_name.clone(), - )), - _ => None, - } - } else { - None - }; - let pending = if association_mode == 0 { - let layer = self.tabs[i].active_layer.clone(); - if layer != "0" || entity.as_entity().layer().is_empty() { - entity.as_entity_mut().set_layer(layer); - } - crate::scene::view::dispatch::apply_color( - &mut entity, - self.ribbon.active_color, - ); - crate::scene::view::dispatch::apply_common_prop( - &mut entity, - "linetype", - &self.ribbon.active_linetype.clone(), - ); - crate::scene::view::dispatch::apply_line_weight( - &mut entity, - self.ribbon.active_lineweight, - ); - let celtscale = self.tabs[i] - .scene - .document - .header - .current_entity_linetype_scale; - if (celtscale - 1.0).abs() > 1e-9 && celtscale.abs() > 1e-9 { - entity.common_mut().linetype_scale = celtscale; - } - crate::scene::creation_style::apply_current_creation_styles( - &self.tabs[i].scene.document, - &mut entity, - ); - if let (Some((layer, style_name)), acadrust::EntityType::Dimension(dimension)) = - (inherited_dimension, &mut entity) - { - dimension.base_mut().common.layer = layer; - dimension.base_mut().style_name = style_name; - } - let pieces = crate::modules::draw::modify::explode::explode_entity( - &entity, - &self.tabs[i].scene.document, - ); - let delta_safe = pieces.iter().all(|piece| self.delta_add_safe(i, piece)); - let pending = self.begin_undo(i, label, pieces.len(), delta_safe); - for piece in pieces { - self.tabs[i].scene.add_entity(piece); - } - pending - } else { - let delta_safe = self.delta_add_safe(i, &entity); - let pending = self.begin_undo(i, label, 1, delta_safe); - if let Some(handle) = - self.commit_entity_handle_with_dimension_policy(entity, preserve_base_style) - { - if association_mode == 2 && association_allowed { - let mut changes = vec![(handle, crate::scene::ChangeKind::Modified)]; - match association { - crate::command::DimensionAssociationInput::Infer(source) => { - let sources: Vec<_> = source.map_or_else( - || self.infer_dimension_sources_guarded(i, handle), - |source| { - if single_source_dimension { - vec![Some(source)] - } else { - vec![Some(source), Some(source)] - } - }, - ); - self.tabs[i] - .scene - .attach_dimension_association(handle, sources.clone()); - changes.extend(sources.into_iter().flatten().map(|source| { - (source, crate::scene::ChangeKind::Modified) - })); - } - crate::command::DimensionAssociationInput::Explicit(sources) => { - self.tabs[i].scene.attach_dimension_association_sources( - handle, - sources.clone(), - ); - changes.extend(sources.into_iter().flatten().map(|source| { - (source.handle, crate::scene::ChangeKind::Modified) - })); - } - } - changes.sort_by_key(|(handle, _)| handle.value()); - changes.dedup_by_key(|(handle, _)| handle.value()); - self.tabs[i].scene.bump_entities(&changes); - } else if association_mode == 2 { - // Preserve the acquired viewport and source paths. - self.attach_viewport_dimension_association(i, handle); - } - } - pending - }; - self.tabs[i].dirty = true; - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].snap_result = None; - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - if continue_command { - if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|cmd| cmd.prompt()) { - self.command_line.push_info(&prompt); - } - } else { - self.tabs[i].active_cmd = None; - self.restore_pre_cmd_tangent(); - } - } - CmdResult::CommitDimensionsAndExit(dimensions) => { - let association_mode = self.tabs[i].scene.document.header.dimension_associativity; - let mut made = 0; - let pending = if association_mode == 0 { - let mut pieces = Vec::new(); - for (mut entity, _) in dimensions { - let layer = self.tabs[i].active_layer.clone(); - if layer != "0" || entity.as_entity().layer().is_empty() { - entity.as_entity_mut().set_layer(layer); - } - crate::scene::view::dispatch::apply_color( - &mut entity, - self.ribbon.active_color, - ); - crate::scene::view::dispatch::apply_common_prop( - &mut entity, - "linetype", - &self.ribbon.active_linetype.clone(), - ); - crate::scene::view::dispatch::apply_line_weight( - &mut entity, - self.ribbon.active_lineweight, - ); - let celtscale = self.tabs[i] - .scene - .document - .header - .current_entity_linetype_scale; - if (celtscale - 1.0).abs() > 1e-9 && celtscale.abs() > 1e-9 { - entity.common_mut().linetype_scale = celtscale; - } - crate::scene::creation_style::apply_current_creation_styles( - &self.tabs[i].scene.document, - &mut entity, - ); - let exploded = crate::modules::draw::modify::explode::explode_entity( - &entity, - &self.tabs[i].scene.document, - ); - if !exploded.is_empty() { - made += 1; - } - pieces.extend(exploded); - } - let delta_safe = pieces.iter().all(|piece| self.delta_add_safe(i, piece)); - let pending = self.begin_undo(i, "QDIM", pieces.len(), delta_safe); - for piece in pieces { - self.tabs[i].scene.add_entity(piece); - } - pending - } else { - let delta_safe = dimensions - .iter() - .all(|(entity, _)| self.delta_add_safe(i, entity)); - let pending = self.begin_undo(i, "QDIM", dimensions.len(), delta_safe); - for (entity, association) in dimensions { - let Some(handle) = self.commit_entity_handle(entity) else { - continue; - }; - made += 1; - if association_mode != 2 { - continue; - } - let sources = match association { - crate::command::DimensionAssociationInput::Infer(source) => { - source.map_or_else( - || { - self.infer_dimension_sources_guarded(i, handle) - .into_iter() - .map(|source| { - source.map( - crate::command::DimensionAssociationSource::inferred, - ) - }) - .collect() - }, - |source| { - vec![Some( - crate::command::DimensionAssociationSource::inferred( - source, - ), - )] - }, - ) - } - crate::command::DimensionAssociationInput::Explicit(sources) => { - sources - } - }; - self.tabs[i] - .scene - .attach_dimension_association_sources(handle, sources.clone()); - let mut changes = vec![(handle, crate::scene::ChangeKind::Modified)]; - changes.extend( - sources - .into_iter() - .flatten() - .map(|source| (source.handle, crate::scene::ChangeKind::Modified)), - ); - changes.sort_by_key(|(handle, _)| handle.value()); - changes.dedup_by_key(|(handle, _)| handle.value()); - self.tabs[i].scene.bump_entities(&changes); - } - pending - }; - if made > 0 { - self.tabs[i].dirty = true; - } - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - self.command_line - .push_output(crate::tf!("QDIM {made} dimensions created.").as_ref()); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - CmdResult::SetQuickDimensionSnapPriority(priority) => { - self.quick_dimension_snap_priority = priority.min(1); - self.persist_settings_if_changed(); - let prompt = self.tabs[i] - .active_cmd - .as_ref() - .map(|command| command.prompt()); - if let Some(prompt) = prompt { - self.command_line.push_info(&prompt); - } - } - CmdResult::AlignMLeaders { - mut handles, - from, - to, - } => { - handles.retain(|handle| { - !self.tabs[i].scene.is_layer_locked(*handle) - && matches!( - self.tabs[i].scene.document.get_entity(*handle), - Some(acadrust::EntityType::MultiLeader(_)) - ) - }); - if !handles.is_empty() { - self.push_undo_snapshot(i, "MLEADERALIGN"); - if apply_mleader_align(&mut self.tabs[i].scene, &handles, from, to) { - self.tabs[i].dirty = true; - self.command_line - .push_output(crate::t!("MLEADERALIGN Leaders aligned.").as_ref()); - } - } - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - } - CmdResult::CollectMLeaders { handles, point } => { - let compatible = compatible_mleader_collect_handles(&self.tabs[i].scene, &handles); - if compatible.len() >= 2 { - self.push_undo_snapshot(i, "MLEADERCOLLECT"); - if apply_mleader_collect(&mut self.tabs[i].scene, &compatible, point) { - self.tabs[i].dirty = true; - self.command_line - .push_output(crate::t!("MLEADERCOLLECT Leaders collected.").as_ref()); - } - } - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - } - CmdResult::CommitSolid { - entity, - solid, - history, - erase_source, - } => { - let label = self.history_label_from_active_cmd(i, "SOLID"); - let erase_source = erase_source.filter(|handle| { - self.delete_objects != 0 && !self.tabs[i].scene.is_layer_locked(*handle) - }); - let pending = - self.begin_undo(i, label, 1 + usize::from(erase_source.is_some()), true); - let handle = self.add_solid_model(entity, *solid, history); - if !handle.is_null() { - if let Some(source) = erase_source { - self.tabs[i].scene.erase_entities(&[source]); - } - self.tabs[i].dirty = true; - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - } - CmdResult::CommitAndEditText(entity) => { - let annotative_mleader = matches!( - &entity, - acadrust::EntityType::MultiLeader(ml) if ml.enable_annotation_scale - ); - let label = self.history_label_from_active_cmd(i, "ENTITY"); - let delta_safe = self.delta_add_safe(i, &entity) && !annotative_mleader; - let pending = self.begin_undo(i, label, 1, delta_safe); - let handle = self.commit_entity_handle(entity); - - if annotative_mleader { - let scale_handle = self.tabs[i].scene.creation_annotation_scale_handle(); - if let (Some(handle), Some(scale_handle)) = (handle, scale_handle) { - crate::scene::annotative::create_annotation_context( - &mut self.tabs[i].scene.document, - handle, - scale_handle, - ); - self.tabs[i] - .scene - .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); - } - } - - self.tabs[i].dirty = true; - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - self.ribbon.deactivate_tool(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - if let Some(h) = handle { - return self.begin_text_edit(h); - } - } - CmdResult::CommitManyAndEditText { - entities, - edit_index, - open_editor, - } => { - let label = self.history_label_from_active_cmd(i, "ENTITY"); - let delta_safe = entities.iter().all(|entity| self.delta_add_safe(i, entity)); - let pending = self.begin_undo(i, label, entities.len(), delta_safe); - let mut edit_handle = None; - let mut leader_handle = None; - for (idx, entity) in entities.into_iter().enumerate() { - let is_leader = matches!(entity, acadrust::EntityType::Leader(_)); - let h = self.commit_entity_handle(entity); - if idx == edit_index { - edit_handle = h; - } - if is_leader { - leader_handle = h; - } - } - // Link the leader to its annotation so the pair edits as a unit - // (double-click on the leader resolves to the text entity). - if let (Some(lh), Some(ah)) = (leader_handle, edit_handle) { - let linked = if let Some(acadrust::EntityType::Leader(l)) = - self.tabs[i].scene.document.get_entity_mut(lh) - { - l.annotation_handle = ah; - true - } else { - false - }; - - if linked { - // The LEADER may already have received its annotation context while - // annotation_handle was still NULL. Refresh it now that the MTEXT link - // is known so the context represents the finished leader. - self.tabs[i].scene.sync_displayed_annotation_context(lh); - - self.tabs[i] - .scene - .bump_entities(&[(lh, crate::scene::ChangeKind::Modified)]); - } - } - self.tabs[i].dirty = true; - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - self.ribbon.deactivate_tool(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - if open_editor { - if let Some(h) = edit_handle { - return self.begin_text_edit(h); - } - } - } - CmdResult::CreateBlock { - mut handles, - name, - base, - } => { - handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); - if handles.is_empty() { - self.command_line - .push_info(crate::t!("No editable objects selected.").as_ref()); - return Task::none(); - } - self.push_undo_snapshot(i, "BLOCK"); - let ucs = self.tabs[i].ucs_xform(); - let world_to_block = ucs.to_ucs_transform_at(base); - let block_to_world = ucs.to_wcs_transform_at(base); - match self.tabs[i].scene.create_block_from_entities( - &handles, - &name, - &world_to_block, - &block_to_world, - ) { - Ok(insert_handle) => { - self.tabs[i].dirty = true; - self.tabs[i].scene.deselect_all(); - if !insert_handle.is_null() { - self.tabs[i].scene.select_entity(insert_handle, false); - } - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.command_line - .push_output(crate::tf!("Block \"{name}\" created.").as_ref()); - self.refresh_properties(); - } - Err(err) => { - self.discard_last_undo_entry(i); - self.command_line.push_error(&err); - let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); - if let Some(p) = prompt { - self.command_line.push_info(&p); - } - } - } - } - CmdResult::CreateBlockWithOptions { mut options } => { - options.handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); - if options.handles.is_empty() { - self.command_line - .push_info(crate::t!("No editable objects selected.").as_ref()); - return Task::none(); - } - self.push_undo_snapshot(i, "BLOCK"); - let name = options.name.clone(); - match self.tabs[i].scene.create_block_with_options(*options) { - Ok(insert_handle) => { - self.tabs[i].dirty = true; - self.tabs[i].scene.deselect_all(); - if !insert_handle.is_null() { - self.tabs[i].scene.select_entity(insert_handle, false); - } - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.command_line - .push_output(crate::tf!("Block \"{name}\" created.").as_ref()); - self.refresh_properties(); - self.refresh_block_palette(); - } - Err(err) => { - self.discard_last_undo_entry(i); - self.command_line.push_error(&err); - } - } - } - CmdResult::CommitHatch(hatch) => { - let label = self.history_label_from_active_cmd(i, "HATCH"); - let pending = self.begin_undo(i, label, 1, true); - let layer = self.tabs[i].active_layer.clone(); - let new_handle = self.tabs[i].scene.add_hatch(hatch, Some(&layer), None); - if !new_handle.is_null() { - self.tabs[i].scene.select_entity(new_handle, true); - } - self.tabs[i].dirty = true; - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - self.refresh_properties(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - CmdResult::CommitStyledHatch { - hatch, - color, - transparency, - } => { - let label = self.history_label_from_active_cmd(i, "HATCH"); - let pending = self.begin_undo(i, label, 1, true); - let layer = self.tabs[i].active_layer.clone(); - let new_handle = - self.tabs[i] - .scene - .add_hatch(hatch, Some(&layer), Some((color, transparency))); - if !new_handle.is_null() { - self.tabs[i].scene.select_entity(new_handle, true); - } - self.tabs[i].dirty = true; - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - self.refresh_properties(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - CmdResult::CommitHatchWithBoundaries { - mut hatch, - boundaries, - entity_style, - } => { - let label = self.history_label_from_active_cmd(i, "HATCH"); - let pending = self.begin_undo(i, label, boundaries.len() + 1, true); - let mut sources = Vec::with_capacity(boundaries.len()); - for boundary in boundaries { - let handles = self - .commit_entity_handle(boundary) - .into_iter() - .collect::>(); - sources.push(handles); - } - if let Some(paths) = hatch.boundary_paths.as_mut() { - for (path, handles) in std::sync::Arc::make_mut(paths).iter_mut().zip(&sources) - { - path.boundary_handles = handles.clone(); - path.flags.set_external(!handles.is_empty()); - } - } - hatch.boundary_sources = Some(std::sync::Arc::new(sources)); - let layer = self.tabs[i].active_layer.clone(); - let new_handle = self.tabs[i] - .scene - .add_hatch(hatch, Some(&layer), entity_style); - if !new_handle.is_null() { - self.tabs[i].scene.select_entity(new_handle, true); - } - self.tabs[i].dirty = true; - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - self.refresh_properties(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - CmdResult::CommitHatches { - hatches, - entity_style, - } => { - let label = self.history_label_from_active_cmd(i, "HATCH"); - let pending = self.begin_undo(i, label, hatches.len(), true); - let layer = self.tabs[i].active_layer.clone(); - for hatch in hatches { - let new_handle = - self.tabs[i] - .scene - .add_hatch(hatch, Some(&layer), entity_style.clone()); - if !new_handle.is_null() { - self.tabs[i].scene.select_entity(new_handle, true); - } - } - self.tabs[i].dirty = true; - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - self.refresh_properties(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - CmdResult::BatchCopy(mut handles, transforms) => { - handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); - if handles.is_empty() { - self.tabs[i].active_cmd = None; - return Task::none(); - } - let label = self.history_label_from_active_cmd(i, "ARRAY"); - let count = transforms.len(); - // Same gate as COPY (dimension-free), sized by the total number - // of copies the array will add. - let delta_safe = self.delta_copy_safe(i, &handles); - let pending = self.begin_undo(i, label.clone(), handles.len() * count, delta_safe); - for t in &transforms { - self.tabs[i].scene.copy_entities(&handles, t); - } - self.tabs[i].dirty = true; - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - let noun = if count == 1 { "copy" } else { "copies" }; - self.command_line - .push_output(crate::tf!("{label}: {count} {noun} created.").as_ref()); - self.refresh_properties(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - CmdResult::ReplaceMany(replacements, additions) => { - if let Some((handle, _)) = replacements - .iter() - .find(|(handle, _)| self.tabs[i].scene.is_layer_locked(*handle)) - { - self.reject_locked_edit(i, *handle); - self.tabs[i].active_cmd = None; - return Task::none(); - } - let label = self.history_label_from_active_cmd(i, "FILLET"); - let was_catchment = self.tabs[i] - .active_cmd - .as_ref() - .is_some_and(|c| c.name() == "SS_CATCHMENT"); - self.push_undo_snapshot(i, label); - for (handle, entities) in replacements { - self.replace_command_entity(i, handle, entities); - } - for entity in additions { - self.tabs[i].scene.add_entity(entity); - } - self.tabs[i].dirty = true; - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - if was_catchment { - self.command_line - .push_info(crate::t!("Catchment tagged successfully.").as_ref()); - } - self.refresh_properties(); - } - CmdResult::ReplaceManyContinue(replacements) => { - if let Some((handle, _)) = replacements - .iter() - .find(|(handle, _)| self.tabs[i].scene.is_layer_locked(*handle)) - { - self.reject_locked_edit(i, *handle); - return Task::none(); - } - let label = self.history_label_from_active_cmd(i, "TRIM"); - self.push_undo_snapshot(i, label); - for (handle, entities) in replacements { - let new_handles = self.replace_command_entity(i, handle, entities); - if let Some(command) = self.tabs[i].active_cmd.as_mut() { - command.on_entity_replaced(handle, &new_handles); - } - } - self.tabs[i].dirty = true; - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].snap_result = None; - if let Some(prompt) = self.tabs[i] - .active_cmd - .as_ref() - .map(|command| command.prompt()) - { - self.command_line.push_info(&prompt); - } - self.refresh_properties(); - } - CmdResult::UpdateEntityAndFinish { handle, entity } => { - if self.reject_locked_edit(i, handle) { - self.tabs[i].active_cmd = None; - return Task::none(); - } - let label = self.history_label_from_active_cmd(i, "EDIT"); - self.push_undo_snapshot(i, label); - let is_dimension = matches!(entity, acadrust::EntityType::Dimension(_)); - if let Some(current) = self.tabs[i].scene.document.get_entity_mut(handle) { - *current = entity; - if is_dimension { - self.tabs[i].scene.invalidate_dim_block_recorded(handle); - } - self.tabs[i] - .scene - .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); - self.tabs[i].dirty = true; - } else { - self.command_line.push_error( - crate::t!("The selected object is no longer available.").as_ref(), - ); - } - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.refresh_properties(); - } - CmdResult::AddParametricConstraint { - kind, - refs, - driving_param, - label, - } => { - use crate::scene::parametric_constraints::ConstraintKind; - // GCSMOOTH and FXCONSTRAINT re-prompt after a rejected pick. - let keep_command = self.tabs[i].active_cmd.as_ref().is_some_and(|command| { - (kind == ConstraintKind::Smooth && command.name() == "GCSMOOTH") - || (kind == ConstraintKind::Fixed && command.name() == "FXCONSTRAINT") - }); - if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( - kind, - &refs, - driving_param.as_ref(), - ) { - if !keep_command { - self.tabs[i].active_cmd = None; - } - self.tabs[i].snap_result = None; - self.command_line.push_error(message); - if keep_command { - if let Some(prompt) = - self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()) - { - self.command_line.push_info(&prompt); - } - } - return Task::none(); - } - let scope = self.tabs[i].current_parametric_scope(); - if kind == ConstraintKind::Fixed - && self.tabs[i] - .scene - .parametric_constraint_set(scope) - .is_some_and(|set| { - set.constraints.iter().any(|existing| { - existing.enabled && existing.kind == kind && existing.refs == refs - }) - }) - { - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.command_line - .push_error("The constraint already exists on the selected objects."); - return Task::none(); - } - let constraints_before = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .cloned() - .unwrap_or_else(|| { - crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) - }); - let touched: Vec = refs.iter().map(|r| r.entity).collect(); - let pending = self.begin_undo(i, label, touched.len(), true); - self.tabs[i] - .scene - .record_undo_parametric_constraints_before(scope, constraints_before); - let retain_size = self.constraint_solve_mode && driving_param.is_none(); - let id = self.tabs[i].scene.parametric_constraint_set_mut(scope).add( - kind, - refs, - driving_param, - ); - self.tabs[i].scene.note_parametric_constraint_applied( - scope, - id, - self.constraint_bar_display, - ); - let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched - .into_iter() - .map(|h| (h, crate::scene::ChangeKind::Modified)) - .collect(); - self.tabs[i].scene.bump_entities_with_parametric_policy( - &changes, - &[], - retain_size, - ); - self.tabs[i].dirty = true; - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.command_line.push_output("Constraint applied."); - self.refresh_properties(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - CmdResult::AddEqualConstraint { - first, - others, - multiple, - label, - } => { - use crate::modules::parametric::EqualConstraintCommand; - use crate::scene::parametric_constraints::{ - equal_size, equal_size_follower, ConstraintKind, EqualSize, ParametricRef, - }; - - let scope = self.tabs[i].current_parametric_scope(); - // Enter ends a Multiple flow with the reference's summary line. - let finishing = multiple && others.is_empty(); - let mut followers: Vec = Vec::new(); - for other in others { - let refs = [first, other]; - if other == first - || self - .tabs[i] - .scene - .validate_parametric_constraint(ConstraintKind::Equal, &refs, None) - .is_err() - || equal_size_follower(&self.tabs[i].scene.document, first, other) - .is_none() - { - // The reference names the kind the first object - // asks for. - let message = match equal_size(&self.tabs[i].scene.document, first) { - Some(EqualSize::Radius(_)) => EqualConstraintCommand::INVALID_RADIUS_OBJECT, - _ => EqualConstraintCommand::INVALID_LENGTH_OBJECT, - }; - self.command_line.push_error(message); - continue; - } - let exists = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .is_some_and(|set| { - set.constraints.iter().any(|existing| { - existing.enabled - && existing.kind == ConstraintKind::Equal - && (existing.refs == refs || existing.refs == [other, first]) - }) - }); - if exists { - self.command_line - .push_error("The constraint already exists on the selected objects."); - continue; - } - followers.push(other); - } - // A refused second object is asked for again, as in the - // reference; Multiple keeps asking anyway. - let keep = (multiple && !finishing) || (!multiple && followers.is_empty()); - if keep { - if let Some(prompt) = - self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()) - { - self.command_line.push_info(&prompt); - } - } else { - self.tabs[i].active_cmd = None; - } - self.tabs[i].snap_result = None; - if finishing { - let summary = match equal_size(&self.tabs[i].scene.document, first) { - Some(EqualSize::Radius(_)) => "Radius of objects made equal", - _ => "Length of objects made equal", - }; - self.command_line.push_output(summary); - } - if followers.is_empty() { - return Task::none(); - } - let constraints_before = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .cloned() - .unwrap_or_else(|| { - crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) - }); - let mut touched = vec![first.entity]; - for follower in &followers { - if !touched.contains(&follower.entity) { - touched.push(follower.entity); - } - } - let pending = self.begin_undo(i, label, touched.len(), true); - self.tabs[i] - .scene - .record_undo_parametric_constraints_before(scope, constraints_before); - // The reference resizes the follower in place — its start (a - // circle its center) and direction stay, only its length or - // radius takes the first object's — so do that first and let - // the relation then hold what already fits. - for follower in &followers { - if let Some(resized) = - equal_size_follower(&self.tabs[i].scene.document, first, *follower) - { - self.tabs[i].scene.update_entity(resized); - } - let id = self.tabs[i] - .scene - .parametric_constraint_set_mut(scope) - .add(ConstraintKind::Equal, vec![first, *follower], None); - self.tabs[i].scene.note_parametric_constraint_applied( - scope, - id, - self.constraint_bar_display, - ); - } - let changes = touched - .into_iter() - .map(|handle| (handle, crate::scene::ChangeKind::Modified)) - .collect::>(); - self.tabs[i].scene.bump_entities_with_parametric_policy( - &changes, - &[], - self.constraint_solve_mode, - ); - self.tabs[i].dirty = true; - self.refresh_properties(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - CmdResult::CheckConstraintPoint(pick) => { - use crate::scene::parametric_constraints::{ - nearest_parametric_point, nearest_parametric_point_on_entity, resolve_point, - }; - let scope = self.tabs[i].current_parametric_scope(); - let resolved = { - let document = &self.tabs[i].scene.document; - let world = - acadrust::types::Vector3::new(pick.point.x, pick.point.y, pick.point.z); - let reference = match pick.handle { - Some(handle) => { - nearest_parametric_point_on_entity(document, scope, handle, world) - } - None => nearest_parametric_point(document, scope, world, None), - }; - reference.map(|reference| { - let point = document - .get_entity(reference.entity) - .zip(reference.marker) - .and_then(|(entity, marker)| resolve_point(entity, marker)) - .map_or(pick.point, |p| glam::DVec3::new(p.x, p.y, p.z)); - (reference, point) - }) - }; - let Some((reference, point)) = resolved else { - return self.apply_cmd_result(CmdResult::ReportError( - crate::modules::parametric::DimConstraintCommand::NO_POINT.to_string(), - )); - }; - let result = self.tabs[i] - .active_cmd - .as_mut() - .map(|command| command.accept_constraint_point(reference, point)); - return match result { - Some(result) => self.apply_cmd_result(result), - None => Task::none(), - }; - } - CmdResult::AddDimensionalConstraint { - kind, - first, - second, - first_point, - second_point, - location, - axis, - direction, - name, - expression, - renamed, - label, - } => { - use crate::modules::annotate::{aligned_dim, linear_dim}; - use crate::scene::named_parameters::{is_valid_name, DrivingValue}; - use crate::scene::parametric_constraints::{ - distance_direction_type, dynamic_dimension_text, ConstraintKind, - }; - - let scope = self.tabs[i].current_parametric_scope(); - let mut refs = vec![first, second]; - refs.extend(direction); - // The same measurement between the same references is - // refused after the value, and the command ends. - let duplicate = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .is_some_and(|set| { - set.constraints.iter().any(|c| { - c.enabled - && c.kind == kind - && c.refs.len() == refs.len() - && c.refs[..2].contains(&first) - && c.refs[..2].contains(&second) - && c.refs.get(2) == refs.get(2) - }) - }); - if duplicate { - self.command_line - .push_output("The constraint already exists on the selected objects."); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - return Task::none(); - } - // A refused value keeps the prompt open, as the reference's - // editor does. A directed distance validates as the plain - // two-point distance it also is. - let (validate_kind, validate_refs) = if kind == ConstraintKind::DistanceDirected { - (ConstraintKind::Distance, &refs[..2]) - } else { - (kind, &refs[..]) - }; - if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( - validate_kind, - validate_refs, - Some(&DrivingValue::Literal(1.0)), - ) { - self.reprompt_active_command(i, message); - return Task::none(); - } - let anchors = crate::scene::parametric_constraints::dimensional_anchor_refs( - &self.tabs[i].scene.document, - &refs, - ); - let name = name.trim().to_string(); - if !is_valid_name(&name) { - self.command_line.push_error( - "Only alphanumeric names, starting with alpha characters, are allowed.", - ); - self.reprompt_active_command(i, &format!("Invalid parameter name: {name}.")); - return Task::none(); - } - if renamed && self.tabs[i].scene.named_parameters().contains(&name) { - self.reprompt_active_command(i, "Parameter with this name already exists."); - return Task::none(); - } - let mut table = self.tabs[i].scene.named_parameters().clone(); - if let Err(error) = table.set(&name, &expression) { - self.reprompt_active_command(i, &error.to_string()); - return Task::none(); - } - let value = match table.resolve(&name) { - Ok(value) if value.is_finite() => value, - _ => { - self.command_line.push_error("Invalid expression."); - self.reprompt_active_command( - i, - "The parameter is used in an expression which results in an invalid value for a dimensional constraint.", - ); - return Task::none(); - } - }; - // DCFORM Annotational: an ordinary plotted dimension on the - // current layer that shows the value at dimension precision. - let annotational = self.constraint_form_annotational; - let text = dynamic_dimension_text( - &name, - value, - self.tabs[i].scene.constraint_name_format, - false, - Some(&expression), - annotational, - None, - ); - let mut entity = if kind == ConstraintKind::Distance { - aligned_dim::aligned_dimension_entity( - first_point, - second_point, - location, - Some(text), - ) - } else { - linear_dim::linear_dimension_entity( - first_point, - second_point, - location, - axis, - Some(text), - ) - }; - crate::scene::creation_style::apply_current_creation_styles( - &self.tabs[i].scene.document, - &mut entity, - ); - if annotational { - entity - .as_entity_mut() - .set_layer(self.tabs[i].active_layer.clone()); - } else { - // A dynamic dimension lives on the reference's constraints - // layer, hidden from the layer lists and never plotted, and - // draws in the constraint gray, not the current color. - self.tabs[i].scene.ensure_dynamic_dimension_layer(); - entity.as_entity_mut().set_layer( - crate::scene::parametric_constraints::DYNAMIC_DIMENSION_LAYER.to_string(), - ); - entity.common_mut().color = acadrust::types::Color::Rgb { - r: 103, - g: 109, - b: 118, - }; - } - let constraints_before = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .cloned() - .unwrap_or_else(|| { - crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) - }); - let mut touched = vec![first.entity]; - for reference in [Some(second), direction].into_iter().flatten() { - if !touched.contains(&reference.entity) { - touched.push(reference.entity); - } - } - let pending = self.begin_undo(i, label, touched.len() + 1, false); - self.tabs[i] - .scene - .record_undo_parametric_constraints_before(scope, constraints_before); - self.tabs[i].scene.record_undo_named_parameters_before(); - self.tabs[i].scene.named_parameters = table; - let dimension = self.tabs[i].scene.add_entity(entity); - let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); - let id = set.add(kind, refs, Some(DrivingValue::Named(name))); - if direction.is_some() { - if let Some(constraint) = set.constraints.iter_mut().find(|c| c.id == id) { - constraint.distance_direction_type = - distance_direction_type::PERPENDICULAR_TO_LINE; - } - } - set.dimensions.insert(id, dimension); - self.tabs[i].scene.note_parametric_constraint_applied( - scope, - id, - self.constraint_bar_display, - ); - self.tabs[i].scene.attach_dimension_association( - dimension, - vec![Some(first.entity), Some(second.entity)], - ); - let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched - .into_iter() - .map(|handle| (handle, crate::scene::ChangeKind::Modified)) - .collect(); - // The first point (and a line it belongs to that the distance - // runs perpendicular to) stays; a value other than the - // measured one moves the second, as in the reference. Not - // `retain_size`: the typed value is the new length. - self.tabs[i] - .scene - .bump_entities_with_parametric_policy(&changes, &anchors, false); - // DYNCONSTRAINTDISPLAY 0 also keeps a new dynamic dimension off screen. - self.tabs[i].scene.refresh_hidden_dynamic_dimensions(); - self.tabs[i].scene.refresh_dynamic_dimension_scales(true); - self.tabs[i].dirty = true; - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.refresh_properties(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - CmdResult::CancelWithMessage(message) => { - self.command_line.push_error(&message); - return self.apply_cmd_result(CmdResult::Cancel); - } - CmdResult::AddRadialConstraint { - circle, - center, - radius, - location, - diameter, - name, - expression, - renamed, - } => { - use crate::modules::annotate::{diameter_dim, radius_dim}; - use crate::scene::named_parameters::{is_valid_name, DrivingValue}; - use crate::scene::parametric_constraints::{ - dynamic_dimension_text, ConstraintKind, ParametricRef, - }; - - let scope = self.tabs[i].current_parametric_scope(); - let kind = if diameter { - ConstraintKind::Diameter - } else { - ConstraintKind::Radius - }; - let refs = vec![circle]; - // A circle already carrying a radius or diameter constraint - // is refused after the value, as the reference does. - let duplicate = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .is_some_and(|set| { - set.constraints.iter().any(|c| { - c.enabled - && matches!( - c.kind, - ConstraintKind::Radius | ConstraintKind::Diameter - ) - && c.refs.iter().any(|r| r.entity == circle.entity) - }) - }); - if duplicate { - self.command_line - .push_output("The constraint already exists on the selected objects."); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - return Task::none(); - } - if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( - kind, - &refs, - Some(&DrivingValue::Literal(1.0)), - ) { - self.reprompt_active_command(i, message); - return Task::none(); - } - let name = name.trim().to_string(); - if !is_valid_name(&name) { - self.command_line.push_error( - "Only alphanumeric names, starting with alpha characters, are allowed.", - ); - self.reprompt_active_command(i, &format!("Invalid parameter name: {name}.")); - return Task::none(); - } - if renamed && self.tabs[i].scene.named_parameters().contains(&name) { - self.reprompt_active_command(i, "Parameter with this name already exists."); - return Task::none(); - } - let mut table = self.tabs[i].scene.named_parameters().clone(); - if let Err(error) = table.set(&name, &expression) { - self.reprompt_active_command(i, &error.to_string()); - return Task::none(); - } - // A circle has no zero or negative size: the value is - // refused and the prompt comes back. - let value = match table.resolve(&name) { - Ok(value) if value.is_finite() && value > 0.0 => value, - _ => { - self.command_line.push_error("Invalid expression."); - self.reprompt_active_command( - i, - "The parameter is used in an expression which results in an invalid value for a dimensional constraint.", - ); - return Task::none(); - } - }; - let annotational = self.constraint_form_annotational; - let text = dynamic_dimension_text( - &name, - value, - self.tabs[i].scene.constraint_name_format, - false, - Some(&expression), - annotational, - None, - ); - let entity = if diameter { - diameter_dim::diameter_constraint_entity(center, radius, location, Some(text)) - } else { - radius_dim::radius_constraint_entity(center, radius, location, Some(text)) - }; - let Some(mut entity) = entity else { - self.reprompt_active_command(i, "No valid constraint point found."); - return Task::none(); - }; - crate::scene::creation_style::apply_current_creation_styles( - &self.tabs[i].scene.document, - &mut entity, - ); - if annotational { - entity - .as_entity_mut() - .set_layer(self.tabs[i].active_layer.clone()); - } else { - self.tabs[i].scene.ensure_dynamic_dimension_layer(); - entity.as_entity_mut().set_layer( - crate::scene::parametric_constraints::DYNAMIC_DIMENSION_LAYER.to_string(), - ); - entity.common_mut().color = acadrust::types::Color::Rgb { - r: 103, - g: 109, - b: 118, - }; - } - let constraints_before = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .cloned() - .unwrap_or_else(|| { - crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) - }); - // The centre stays where it is; the value moves the rim. - let anchors = vec![ParametricRef::center(circle.entity)]; - let pending = self.begin_undo( - i, - if diameter { - "Diameter constraint" - } else { - "Radius constraint" - }, - 2, - false, - ); - self.tabs[i] - .scene - .record_undo_parametric_constraints_before(scope, constraints_before); - self.tabs[i].scene.record_undo_named_parameters_before(); - self.tabs[i].scene.named_parameters = table; - let dimension = self.tabs[i].scene.add_entity(entity); - let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); - let id = set.add(kind, refs, Some(DrivingValue::Named(name))); - set.dimensions.insert(id, dimension); - self.tabs[i].scene.note_parametric_constraint_applied( - scope, - id, - self.constraint_bar_display, - ); - self.tabs[i] - .scene - .attach_dimension_association(dimension, vec![Some(circle.entity)]); - let changes = vec![(circle.entity, crate::scene::ChangeKind::Modified)]; - self.tabs[i] - .scene - .bump_entities_with_parametric_policy(&changes, &anchors, false); - self.tabs[i].scene.refresh_hidden_dynamic_dimensions(); - self.tabs[i].scene.refresh_dynamic_dimension_scales(true); - self.tabs[i].dirty = true; - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.refresh_properties(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - CmdResult::AddAngularConstraint { - refs, - points, - location, - sector, - name, - expression, - renamed, - } => { - use crate::modules::annotate::angular_dim; - use crate::scene::named_parameters::{is_valid_name, DrivingValue}; - use crate::scene::parametric_constraints::{ - angle_decimals, dynamic_dimension_text, ConstraintKind, ParametricRef, - }; - - let scope = self.tabs[i].current_parametric_scope(); - let kind = if refs.len() == 2 { - ConstraintKind::Angle - } else { - ConstraintKind::Angle3Point - }; - // The same angle between the same sides is refused after the - // value (whichever sector), and the command ends. - let duplicate = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .is_some_and(|set| { - set.constraints.iter().any(|c| { - c.enabled - && c.kind == kind - && c.refs.len() == refs.len() - && if kind == ConstraintKind::Angle { - c.refs.contains(&refs[0]) && c.refs.contains(&refs[1]) - } else { - c.refs == refs - } - }) - }); - if duplicate { - self.command_line - .push_output("The constraint already exists on the selected objects."); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - return Task::none(); - } - if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( - kind, - &refs, - Some(&DrivingValue::Literal(1.0)), - ) { - self.reprompt_active_command(i, message); - return Task::none(); - } - let name = name.trim().to_string(); - if !is_valid_name(&name) { - self.command_line.push_error( - "Only alphanumeric names, starting with alpha characters, are allowed.", - ); - self.reprompt_active_command(i, &format!("Invalid parameter name: {name}.")); - return Task::none(); - } - if renamed && self.tabs[i].scene.named_parameters().contains(&name) { - self.reprompt_active_command(i, "Parameter with this name already exists."); - return Task::none(); - } - let mut table = self.tabs[i].scene.named_parameters().clone(); - if let Err(error) = table.set(&name, &expression) { - self.reprompt_active_command(i, &error.to_string()); - return Task::none(); - } - let value = match table.resolve(&name) { - Ok(value) if value.is_finite() => value, - _ => { - self.command_line.push_error("Invalid expression."); - self.reprompt_active_command( - i, - "The parameter is used in an expression which results in an invalid value for a dimensional constraint.", - ); - return Task::none(); - } - }; - let annotational = self.constraint_form_annotational; - let decimals = angle_decimals(&self.tabs[i].scene.document, None); - let text = dynamic_dimension_text( - &name, - value, - self.tabs[i].scene.constraint_name_format, - false, - Some(&expression), - annotational, - Some(decimals), - ); - let entity = if kind == ConstraintKind::Angle { - angular_dim::angular_two_line_entity( - points[0], points[1], points[2], points[3], location, Some(text), - ) - } else { - angular_dim::angular_three_point_entity( - points[0], points[1], points[2], location, Some(text), - ) - }; - let Some(mut entity) = entity else { - self.reprompt_active_command(i, "No valid constraint point found."); - return Task::none(); - }; - crate::scene::creation_style::apply_current_creation_styles( - &self.tabs[i].scene.document, - &mut entity, - ); - if annotational { - entity - .as_entity_mut() - .set_layer(self.tabs[i].active_layer.clone()); - } else { - self.tabs[i].scene.ensure_dynamic_dimension_layer(); - entity.as_entity_mut().set_layer( - crate::scene::parametric_constraints::DYNAMIC_DIMENSION_LAYER.to_string(), - ); - entity.common_mut().color = acadrust::types::Color::Rgb { - r: 103, - g: 109, - b: 118, - }; - } - let constraints_before = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .cloned() - .unwrap_or_else(|| { - crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) - }); - let mut touched: Vec = Vec::new(); - for reference in &refs { - if !touched.contains(&reference.entity) { - touched.push(reference.entity); - } - } - // The first side (or the vertex and first point) stays; a - // value other than the measured one turns the other side. - let anchors: Vec = - crate::scene::parametric_constraints::dimensional_anchor_refs( - &self.tabs[i].scene.document, - &refs, - ); - let association = if kind == ConstraintKind::Angle { - vec![ - Some(refs[0].entity), - Some(refs[0].entity), - Some(refs[1].entity), - Some(refs[1].entity), - ] - } else { - vec![ - Some(refs[1].entity), - Some(refs[0].entity), - Some(refs[2].entity), - ] - }; - let pending = self.begin_undo(i, "Angular constraint", touched.len() + 1, false); - self.tabs[i] - .scene - .record_undo_parametric_constraints_before(scope, constraints_before); - self.tabs[i].scene.record_undo_named_parameters_before(); - self.tabs[i].scene.named_parameters = table; - let dimension = self.tabs[i].scene.add_entity(entity); - let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); - let id = set.add(kind, refs, Some(DrivingValue::Named(name))); - if let Some(constraint) = set.constraints.iter_mut().find(|c| c.id == id) { - constraint.angle_sector = sector; - } - set.dimensions.insert(id, dimension); - self.tabs[i].scene.note_parametric_constraint_applied( - scope, - id, - self.constraint_bar_display, - ); - self.tabs[i] - .scene - .attach_dimension_association(dimension, association); - let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched - .into_iter() - .map(|handle| (handle, crate::scene::ChangeKind::Modified)) - .collect(); - self.tabs[i] - .scene - .bump_entities_with_parametric_policy(&changes, &anchors, false); - self.tabs[i].scene.refresh_hidden_dynamic_dimensions(); - self.tabs[i].scene.refresh_dynamic_dimension_scales(true); - self.tabs[i].dirty = true; - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.refresh_properties(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - CmdResult::MakeParallel { - first_line, - first_ends, - first_pick, - second_line, - second_ends, - } => { - use crate::scene::parametric_constraints::{ - resolve_point, two_lines_placed_entity, ConstraintKind, - }; - let scope = self.tabs[i].current_parametric_scope(); - let refs = [first_line, second_line]; - if let Err(message) = - self.tabs[i] - .scene - .validate_parametric_constraint(ConstraintKind::Parallel, &refs, None) - { - return self.apply_cmd_result(CmdResult::ReportError(message.to_string())); - } - // The reference's placement of the second line, written - // before the Parallel constraint holds it there. Built out of - // line and boxed: this arm's locals would otherwise sit in - // `apply_cmd_result`'s frame, which is on every click's stack. - let placed_entity = two_lines_placed_entity( - &self.tabs[i].scene.document, - first_ends, - first_pick, - second_line, - second_ends, - ); - let already = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .is_some_and(|set| { - set.constraints.iter().any(|c| { - c.enabled - && c.kind == ConstraintKind::Parallel - && c.refs.contains(&first_line) - && c.refs.contains(&second_line) - }) - }); - if !already || placed_entity.is_some() { - let constraints_before = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .cloned() - .unwrap_or_else(|| { - crate::scene::parametric_constraints::ParametricConstraintSet::new( - scope, - ) - }); - let pending = self.begin_undo(i, "Parallel constraint", 1, true); - if let Some(entity) = placed_entity { - self.tabs[i].scene.replace_entity_recorded(entity); - } - self.tabs[i] - .scene - .record_undo_parametric_constraints_before(scope, constraints_before); - if !already { - let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); - let id = set.add(ConstraintKind::Parallel, refs.to_vec(), None); - self.tabs[i].scene.note_parametric_constraint_applied( - scope, - id, - self.constraint_bar_display, - ); - } - // Both lines' ends hold the placement; the solve only - // settles whatever else is tied to them. - let mut pins = first_ends.to_vec(); - pins.extend(second_ends); - self.tabs[i].scene.bump_entities_with_parametric_policy( - &[(second_line.entity, crate::scene::ChangeKind::Modified)], - &pins, - self.constraint_solve_mode, - ); - self.tabs[i].dirty = true; - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - let ends = { - let document = &self.tabs[i].scene.document; - document.get_entity(second_line.entity).and_then(|entity| { - let world = |reference: crate::scene::parametric_constraints::ParametricRef| { - let point = resolve_point(entity, reference.marker?)?; - Some((reference, glam::DVec3::new(point.x, point.y, point.z))) - }; - Some([world(second_ends[0])?, world(second_ends[1])?]) - }) - }; - let Some(ends) = ends else { - return self.apply_cmd_result(CmdResult::ReportError( - crate::modules::parametric::DimConstraintCommand::NO_OBJECT.to_string(), - )); - }; - let result = self.tabs[i] - .active_cmd - .as_mut() - .map(|command| command.accept_parallel_line(ends)); - return match result { - Some(result) => self.apply_cmd_result(result), - None => Task::none(), - }; - } - CmdResult::AddFixedConstraint(pick) => { - use crate::modules::parametric::FixConstraintCommand; - use crate::scene::parametric_constraints::{ - nearest_parametric_point, nearest_parametric_point_on_entity, - parametric_curve_ref_for_pick, ConstraintKind, - }; - - let scope = self.tabs[i].current_parametric_scope(); - let document = &self.tabs[i].scene.document; - let world = - acadrust::types::Vector3::new(pick.point.x, pick.point.y, pick.point.z); - let resolved = match (pick.whole_curve, pick.handle) { - (true, Some(handle)) => { - parametric_curve_ref_for_pick(document, scope, handle, world) - .ok_or(FixConstraintCommand::INVALID_OBJECT) - } - (true, None) => Err(FixConstraintCommand::NO_OBJECT), - (false, Some(handle)) => { - nearest_parametric_point_on_entity(document, scope, handle, world) - .ok_or(FixConstraintCommand::INVALID_OBJECT) - } - (false, None) => nearest_parametric_point(document, scope, world, None) - .ok_or(FixConstraintCommand::NO_POINT), - }; - // An off-plane or 3D curve is "not a valid object" to the - // reference, not a solver limitation. - let resolved = resolved.and_then(|reference| { - let supported = !pick.whole_curve - || self.tabs[i] - .scene - .validate_parametric_constraint( - ConstraintKind::Fixed, - &[reference], - None, - ) - .is_ok(); - supported - .then_some(reference) - .ok_or(FixConstraintCommand::INVALID_OBJECT) - }); - return match resolved { - Ok(reference) => self.apply_cmd_result(CmdResult::AddParametricConstraint { - kind: ConstraintKind::Fixed, - refs: vec![reference], - driving_param: None, - label: "Fixed constraint", - }), - // A miss re-prompts: `ReportError` keeps the command. - Err(message) => { - self.apply_cmd_result(CmdResult::ReportError(message.to_string())) - } - }; - } - CmdResult::CheckHorizontalPoint { kind, pick } => { - use crate::modules::parametric::HorizontalConstraintCommand; - use crate::scene::parametric_constraints::nearest_parametric_point; - - // The reference rejects a missed first point right away and - // asks for it again; a hit moves on to the second point. - let scope = self.tabs[i].current_parametric_scope(); - let found = nearest_parametric_point( - &self.tabs[i].scene.document, - scope, - acadrust::types::Vector3::new(pick.point.x, pick.point.y, pick.point.z), - None, - ) - .is_some(); - if !found { - self.command_line - .push_error("No valid constraint point found."); - } - let command = HorizontalConstraintCommand::resume(kind, found.then_some(pick)); - self.command_line - .push_info(&crate::command::CadCommand::prompt(&command)); - self.tabs[i].active_cmd = Some(Box::new(command)); - self.tabs[i].snap_result = None; - return Task::none(); - } - CmdResult::AddHorizontalConstraint { - kind, - selection, - direction, - label, - } => { - use crate::command::{EntityTransform, HorizontalConstraintSelection}; - use crate::modules::parametric::HorizontalConstraintCommand; - use crate::scene::parametric_constraints::{ - nearest_parametric_point, nearest_parametric_point_on_entity, resolve_point, - ConstraintKind, DirectionalAxis, ParametricRef, - }; - - let vertical = kind == ConstraintKind::Vertical; - let axis = if vertical { "Vertical" } else { "Horizontal" }; - let scope = self.tabs[i].current_parametric_scope(); - let to_world = |point: glam::DVec3| { - acadrust::types::Vector3::new(point.x, point.y, point.z) - }; - let (refs, initial_fixed) = match selection { - HorizontalConstraintSelection::Reference(reference) => { - // The reference turns the object about its first - // vertex: a line's start, the picked polyline - // segment's first vertex, a text's insertion point, - // an ellipse's center. - let initial_fixed = match reference.directional_axis() { - Some(DirectionalAxis::EllipseMajor | DirectionalAxis::EllipseMinor) => { - vec![ParametricRef::center(reference.entity)] - } - Some(DirectionalAxis::TextBaseline) => { - vec![ParametricRef::point(reference.entity, 0)] - } - None => vec![ParametricRef::point( - reference.entity, - reference.segment_index().map_or(0, |index| index as i32), - )], - }; - (vec![reference], initial_fixed) - } - HorizontalConstraintSelection::Points(first, second) => { - let resolve = |pick: crate::command::CoincidentPick| { - if let Some(handle) = pick.handle { - nearest_parametric_point_on_entity( - &self.tabs[i].scene.document, - scope, - handle, - to_world(pick.point), - ) - } else { - nearest_parametric_point( - &self.tabs[i].scene.document, - scope, - to_world(pick.point), - None, - ) - } - }; - // A miss asks for that point again, keeping a good - // first pick; the same point twice asks for another - // second point — the reference's own re-prompts. - let (first_ref, second_ref) = match (resolve(first), resolve(second)) { - (Some(first_ref), Some(second_ref)) if first_ref != second_ref => { - (first_ref, second_ref) - } - (first_ref, second_ref) => { - let (message, keep_first) = if first_ref.is_none() { - ("No valid constraint point found.", None) - } else if second_ref.is_none() { - ("No valid constraint point found.", Some(first)) - } else { - ( - "The object or point is already selected. Select a different object or constraint point.", - Some(first), - ) - }; - let command = HorizontalConstraintCommand::resume(kind, keep_first); - self.command_line.push_error(message); - self.command_line - .push_info(&crate::command::CadCommand::prompt(&command)); - self.tabs[i].active_cmd = Some(Box::new(command)); - self.tabs[i].snap_result = None; - return Task::none(); - } - }; - (vec![first_ref, second_ref], vec![first_ref]) - } - }; - let axis_length = direction.x.hypot(direction.y); - if axis_length <= 1.0e-12 { - self.command_line.push_error(&format!( - "{axis}: the current UCS {} axis is not supported.", - if vertical { "Y" } else { "X" } - )); - return Task::none(); - } - let direction = acadrust::types::Vector3::new( - direction.x / axis_length, - direction.y / axis_length, - 0.0, - ); - if let Err(message) = - self.tabs[i].scene.validate_parametric_constraint(kind, &refs, None) - { - self.command_line.push_error(message); - return Task::none(); - } - if self - .tabs[i] - .scene - .parametric_constraint_set(scope) - .is_some_and(|set| set.contains_axis_constraint(kind, &refs, direction)) - { - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.command_line - .push_error("The constraint already exists on the selected objects."); - return Task::none(); - } - let constraints_before = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .cloned() - .unwrap_or_else(|| { - crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) - }); - let mut touched = Vec::new(); - for reference in &refs { - if !touched.contains(&reference.entity) { - touched.push(reference.entity); - } - } - let pending = self.begin_undo(i, label, touched.len(), true); - self.tabs[i] - .scene - .record_undo_parametric_constraints_before(scope, constraints_before); - // Two points on different objects: the reference slides the - // second object rigidly onto the axis through the first - // point instead of re-solving its shape, so move it first - // and let the constraint then hold what already fits. - if let [first_ref, second_ref] = refs.as_slice() { - if first_ref.entity != second_ref.entity { - let position = |reference: &ParametricRef| { - self.tabs[i] - .scene - .document - .get_entity(reference.entity) - .zip(reference.marker) - .and_then(|(entity, marker)| resolve_point(entity, marker)) - }; - if let (Some(first_point), Some(second_point)) = - (position(first_ref), position(second_ref)) - { - let normal = (-direction.y, direction.x); - let offset = (second_point.x - first_point.x) * normal.0 - + (second_point.y - first_point.y) * normal.1; - if offset.abs() > 1.0e-9 { - self.tabs[i].scene.transform_entities( - &[second_ref.entity], - &EntityTransform::Translate(glam::DVec3::new( - -offset * normal.0, - -offset * normal.1, - 0.0, - )), - ); - } - } - } - } - // The solver cannot start from an axis lying exactly across - // the datum (its equations are singular there), so turn the - // object onto the axis about its anchor first — the - // reference turns it about that anchor too — and let the - // constraint hold what already fits. - if let Some((handle, anchor, end, vertex)) = - crate::scene::parametric_constraints::axis_alignment_target( - &self.tabs[i].scene.document, - &refs, - ) - { - let axis = (end.x - anchor.x, end.y - anchor.y); - let length = axis.0.hypot(axis.1); - let target = if axis.0 * direction.x + axis.1 * direction.y >= 0.0 { - (direction.x, direction.y) - } else { - (-direction.x, -direction.y) - }; - let angle = (axis.0 * target.1 - axis.1 * target.0) - .atan2(axis.0 * target.0 + axis.1 * target.1); - if length > 1.0e-9 && angle.abs() > 1.0e-9 { - match vertex { - Some(index) => { - let moved = - self.tabs[i].scene.document.get_entity(handle).cloned(); - if let Some(mut entity) = moved { - if crate::scene::parametric_constraints::set_polyline_vertex( - &mut entity, - index, - anchor.x + length * target.0, - anchor.y + length * target.1, - ) { - self.tabs[i].scene.update_entity(entity); - } - } - } - None => self.tabs[i].scene.transform_entities( - &[handle], - &EntityTransform::Rotate { - center: glam::DVec3::new(anchor.x, anchor.y, anchor.z), - axis: glam::DVec3::Z, - angle_rad: angle, - }, - ), - } - } - } - let id = self.tabs[i] - .scene - .parametric_constraint_set_mut(scope) - .add_axis_constraint(kind, refs, direction); - self.tabs[i].scene.note_parametric_constraint_applied( - scope, - id, - self.constraint_bar_display, - ); - let changes = touched - .into_iter() - .map(|handle| (handle, crate::scene::ChangeKind::Modified)) - .collect::>(); - self.tabs[i].scene.bump_entities_with_parametric_policy( - &changes, - &initial_fixed, - self.constraint_solve_mode, - ); - self.tabs[i].dirty = true; - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.command_line - .push_output(&format!("{axis} constraint applied.")); - self.refresh_properties(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - CmdResult::AddSymmetricConstraint { - selection, - axis, - label, - } => { - use crate::command::SymmetricConstraintSelection; - use crate::scene::parametric_constraints::ConstraintKind; - - let scope = self.tabs[i].current_parametric_scope(); - let to_world = |point: glam::DVec3| { - acadrust::types::Vector3::new(point.x, point.y, point.z) - }; - let resolve_point = |pick: crate::command::CoincidentPick| { - if let Some(handle) = pick.handle { - crate::scene::parametric_constraints::nearest_parametric_point_on_entity( - &self.tabs[i].scene.document, - scope, - handle, - to_world(pick.point), - ) - } else { - crate::scene::parametric_constraints::nearest_parametric_point( - &self.tabs[i].scene.document, - scope, - to_world(pick.point), - None, - ) - } - }; - let (first, second) = match selection { - SymmetricConstraintSelection::Objects(first, second) => (first, second), - SymmetricConstraintSelection::Points(first, second) => { - let (Some(first), Some(second)) = - (resolve_point(first), resolve_point(second)) - else { - self.command_line.push_error( - "Symmetric: select supported endpoints, centers, midpoints, or vertices.", - ); - return Task::none(); - }; - (first, second) - } - }; - if first == second - || axis.entity == first.entity - || axis.entity == second.entity - { - self.command_line - .push_error("Symmetric: select two different references and a separate line axis."); - return Task::none(); - } - let refs = vec![first, second, axis]; - if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( - ConstraintKind::Symmetric, - &refs, - None, - ) { - self.command_line.push_error(message); - return Task::none(); - } - let duplicate = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .is_some_and(|set| { - set.constraints.iter().any(|constraint| { - constraint.enabled - && constraint.kind == ConstraintKind::Symmetric - && matches!(constraint.refs.as_slice(), [a, b, m] - if *m == axis - && ((*a == first && *b == second) - || (*a == second && *b == first))) - }) - }); - if duplicate { - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.command_line - .push_error("The constraint already exists on the selected objects."); - return Task::none(); - } - let constraints_before = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .cloned() - .unwrap_or_else(|| { - crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) - }); - let mut touched = Vec::new(); - for reference in &refs { - if !touched.contains(&reference.entity) { - touched.push(reference.entity); - } - } - let pending = self.begin_undo(i, label, touched.len(), true); - self.tabs[i] - .scene - .record_undo_parametric_constraints_before(scope, constraints_before); - let id = self.tabs[i].scene.parametric_constraint_set_mut(scope).add( - ConstraintKind::Symmetric, - refs, - None, - ); - self.tabs[i].scene.note_parametric_constraint_applied( - scope, - id, - self.constraint_bar_display, - ); - let changes = touched - .into_iter() - .map(|handle| (handle, crate::scene::ChangeKind::Modified)) - .collect::>(); - self.tabs[i].scene.bump_entities_with_initial_parametric_policy( - &changes, - &[first, axis], - false, - ); - self.tabs[i].dirty = true; - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.command_line - .push_output("Symmetric constraint applied."); - self.refresh_properties(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - CmdResult::AddPerpendicularConstraint { - first, - second, - first_fixed, - second_start, - label, - } => { - use crate::scene::parametric_constraints::ConstraintKind; - - let refs = vec![first, second]; - if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( - ConstraintKind::Perpendicular, - &refs, - None, - ) { - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.command_line.push_error(message); - return Task::none(); - } - let scope = self.tabs[i].current_parametric_scope(); - let constraints_before = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .cloned() - .unwrap_or_else(|| { - crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) - }); - let touched = if first.entity == second.entity { - vec![first.entity] - } else { - vec![first.entity, second.entity] - }; - let pending = self.begin_undo(i, label, touched.len(), true); - self.tabs[i] - .scene - .record_undo_parametric_constraints_before(scope, constraints_before); - let id = self.tabs[i].scene.parametric_constraint_set_mut(scope).add( - ConstraintKind::Perpendicular, - refs, - None, - ); - self.tabs[i].scene.note_parametric_constraint_applied( - scope, - id, - self.constraint_bar_display, - ); - let changes = touched - .into_iter() - .map(|handle| (handle, crate::scene::ChangeKind::Modified)) - .collect::>(); - self.tabs[i].scene.bump_entities_with_initial_parametric_policy( - &changes, - &[first_fixed, second_start], - true, - ); - self.tabs[i].dirty = true; - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.command_line.push_output("Constraint applied."); - self.refresh_properties(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - CmdResult::AddTangentConstraint { - first, - second, - label, - } => { - use crate::scene::parametric_constraints::ConstraintKind; - - let refs = vec![first, second]; - if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( - ConstraintKind::Tangent, - &refs, - None, - ) { - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.command_line.push_error(message); - return Task::none(); - } - let scope = self.tabs[i].current_parametric_scope(); - let constraints_before = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .cloned() - .unwrap_or_else(|| { - crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) - }); - let touched = if first.entity == second.entity { - vec![first.entity] - } else { - vec![first.entity, second.entity] - }; - let pending = self.begin_undo(i, label, touched.len(), true); - self.tabs[i] - .scene - .record_undo_parametric_constraints_before(scope, constraints_before); - let id = self.tabs[i].scene.parametric_constraint_set_mut(scope).add( - ConstraintKind::Tangent, - refs, - None, - ); - self.tabs[i].scene.note_parametric_constraint_applied( - scope, - id, - self.constraint_bar_display, - ); - let changes = touched - .into_iter() - .map(|handle| (handle, crate::scene::ChangeKind::Modified)) - .collect::>(); - self.tabs[i].scene.bump_entities_with_initial_parametric_policy( - &changes, - &[first], - true, - ); - self.tabs[i].dirty = true; - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.command_line.push_output("Constraint applied."); - self.refresh_properties(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - CmdResult::AddConcentricConstraint { - first, - second, - label, - } => { - use crate::scene::parametric_constraints::ConstraintKind; - - let refs = vec![first, second]; - if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( - ConstraintKind::Concentric, - &refs, - None, - ) { - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.command_line.push_error(message); - return Task::none(); - } - let scope = self.tabs[i].current_parametric_scope(); - let duplicate = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .is_some_and(|set| { - set.constraints.iter().any(|constraint| { - constraint.enabled - && constraint.kind == ConstraintKind::Concentric - && matches!(constraint.refs.as_slice(), [a, b] - if (*a == first && *b == second) - || (*a == second && *b == first)) - }) - }); - if duplicate { - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.command_line - .push_output("The Concentric constraint already exists."); - return Task::none(); - } - let constraints_before = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .cloned() - .unwrap_or_else(|| { - crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) - }); - let touched = if first.entity == second.entity { - vec![first.entity] - } else { - vec![first.entity, second.entity] - }; - let pending = self.begin_undo(i, label, touched.len(), true); - self.tabs[i] - .scene - .record_undo_parametric_constraints_before(scope, constraints_before); - let id = self.tabs[i].scene.parametric_constraint_set_mut(scope).add( - ConstraintKind::Concentric, - refs, - None, - ); - self.tabs[i].scene.note_parametric_constraint_applied( - scope, - id, - self.constraint_bar_display, - ); - let changes = touched - .into_iter() - .map(|handle| (handle, crate::scene::ChangeKind::Modified)) - .collect::>(); - self.tabs[i].scene.bump_entities_with_initial_parametric_policy( - &changes, - &[first], - true, - ); - self.tabs[i].dirty = true; - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.command_line.push_output("Constraint applied."); - self.refresh_properties(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - CmdResult::AddCoincidentConstraint { - first, - second, - multiple, - label, - } => { - let scope = self.tabs[i].current_parametric_scope(); - let to_world = |p: glam::DVec3| acadrust::types::Vector3::new(p.x, p.y, p.z); - let resolve = |pick: crate::command::CoincidentPick, - exclude: Option| { - if pick.whole_curve { - pick.handle.and_then(|handle| { - crate::scene::parametric_constraints::parametric_curve_ref_for_pick( - &self.tabs[i].scene.document, - scope, - handle, - to_world(pick.point), - ) - }) - } else if let Some(handle) = pick.handle { - crate::scene::parametric_constraints::nearest_parametric_point_on_entity( - &self.tabs[i].scene.document, - scope, - handle, - to_world(pick.point), - ) - } else { - crate::scene::parametric_constraints::nearest_parametric_point( - &self.tabs[i].scene.document, - scope, - to_world(pick.point), - exclude, - ) - } - }; - let resolved_first = resolve(first, None); - let resolved_second = resolved_first - .and_then(|reference| resolve(second, Some(reference.entity))); - let (Some(first_ref), Some(second_ref)) = (resolved_first, resolved_second) else { - self.command_line.push_error( - "Coincident: select a supported endpoint, center, midpoint, vertex, or curve.", - ); - return Task::none(); - }; - let (kind, refs) = match (first.whole_curve, second.whole_curve) { - (false, false) if first_ref != second_ref => ( - crate::scene::parametric_constraints::ConstraintKind::Coincident, - vec![first_ref, second_ref], - ), - (true, false) => ( - crate::scene::parametric_constraints::ConstraintKind::PointOnCurve, - vec![second_ref, first_ref], - ), - (false, true) => ( - crate::scene::parametric_constraints::ConstraintKind::PointOnCurve, - vec![first_ref, second_ref], - ), - _ => { - self.command_line - .push_error("Coincident: select one point and one curve, or two points."); - return Task::none(); - } - }; - if let Err(error) = self.tabs[i] - .scene - .validate_parametric_constraint(kind, &refs, None) - { - self.command_line.push_error(error); - return Task::none(); - } - let touched: Vec<_> = refs.iter().map(|reference| reference.entity).collect(); - let constraints_before = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .cloned() - .unwrap_or_else(|| { - crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) - }); - let pending = self.begin_undo(i, label, touched.len(), true); - self.tabs[i] - .scene - .record_undo_parametric_constraints_before(scope, constraints_before); - let id = self.tabs[i] - .scene - .parametric_constraint_set_mut(scope) - .add(kind, refs, None); - self.tabs[i].scene.note_parametric_constraint_applied( - scope, - id, - self.constraint_bar_display, - ); - let changes: Vec<_> = touched - .iter() - .copied() - .map(|handle| (handle, crate::scene::ChangeKind::Modified)) - .collect(); - self.tabs[i].scene.bump_entities_with_parametric_policy( - &changes, - &[first_ref], - self.constraint_solve_mode, - ); - self.tabs[i].dirty = true; - self.tabs[i].snap_result = None; - if !multiple { - self.tabs[i].active_cmd = None; - } - self.command_line.push_output("Coincident constraint applied."); - if multiple { - if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|cmd| cmd.prompt()) { - self.command_line.push_info(&prompt); - } - self.command_line.set_step_options( - self.tabs[i] - .active_cmd - .as_ref() - .map(|cmd| cmd.options()) - .unwrap_or_default(), - ); - } - self.refresh_properties(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - CmdResult::AddAutoCoincidentConstraints { handles } => { - use crate::scene::parametric_constraints::ConstraintKind; - let scope = self.tabs[i].current_parametric_scope(); - let inferred = self.tabs[i] - .scene - .inferred_coincident_constraints(scope, &handles); - let count = inferred.len(); - if count > 0 { - let constraints_before = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .cloned() - .unwrap_or_else(|| { - crate::scene::parametric_constraints::ParametricConstraintSet::new( - scope, - ) - }); - let pending = self.begin_undo(i, "Coincident auto constrain", handles.len(), true); - self.tabs[i] - .scene - .record_undo_parametric_constraints_before(scope, constraints_before); - for refs in inferred { - let id = self.tabs[i] - .scene - .parametric_constraint_set_mut(scope) - .add(ConstraintKind::Coincident, refs, None); - self.tabs[i].scene.note_parametric_constraint_applied( - scope, - id, - self.constraint_bar_display, - ); - } - let changes: Vec<_> = handles - .iter() - .copied() - .map(|handle| (handle, crate::scene::ChangeKind::Modified)) - .collect(); - self.tabs[i].scene.bump_entities(&changes); - self.tabs[i].dirty = true; - self.refresh_properties(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.command_line - .push_output(format!("{} coincident constraint(s) applied.", count).as_str()); - } - CmdResult::AddPointOnEntityConstraint { - point, - target, - kind, - label, - } => { - let scope = self.tabs[i].current_parametric_scope(); - let to_world = |p: glam::DVec3| acadrust::types::Vector3::new(p.x, p.y, p.z); - let resolved = crate::scene::parametric_constraints::nearest_parametric_point( - &self.tabs[i].scene.document, - scope, - to_world(point), - Some(target), - ); - match resolved { - Some(point_ref) => { - use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; - // `CenterPoint` addresses its whole-entity side via - // the center marker (same solve as Coincident / - // Concentric — `ConstraintKind::CenterPoint`'s own - // doc comment); `Midpoint`/`PointOnCurve` address it - // as a whole entity. - let target_ref = if kind == ConstraintKind::CenterPoint { - ParametricRef::center(target) - } else { - ParametricRef::whole(target) - }; - return self.apply_cmd_result(CmdResult::AddParametricConstraint { - kind, - refs: vec![point_ref, target_ref], - driving_param: None, - label, - }); - } - None => { - self.command_line.push_error( - "Pick didn't land on a point (endpoint, center, or vertex) — enable an Endpoint/Center object snap and try again.", - ); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - } - } - } - CmdResult::AddEqualDistanceConstraint { points, label } => { - let scope = self.tabs[i].current_parametric_scope(); - let to_world = |p: glam::DVec3| acadrust::types::Vector3::new(p.x, p.y, p.z); - let mut refs = Vec::with_capacity(4); - for p in points { - let Some(r) = crate::scene::parametric_constraints::nearest_parametric_point( - &self.tabs[i].scene.document, - scope, - to_world(p), - None, - ) else { - refs.clear(); - break; - }; - refs.push(r); - } - if refs.len() == 4 { - return self.apply_cmd_result(CmdResult::AddParametricConstraint { - kind: crate::scene::parametric_constraints::ConstraintKind::EqualDistance, - refs, - driving_param: None, - label, - }); - } - self.command_line.push_error( - "Equal Distance: a pick didn't land on a point (endpoint, center, or vertex) — enable an Endpoint/Center object snap and try again.", - ); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - } - CmdResult::ReassociateCenterMark { - target, - source, - point, - } => { - if self.reject_locked_edit(i, target) { - return Task::none(); - } - self.push_undo_snapshot(i, "CENTERREASSOCIATE"); - if self.tabs[i] - .scene - .reassociate_center_mark(target, source, point) - { - self.tabs[i].dirty = true; - self.command_line.push_output( - crate::t!("CENTERREASSOCIATE: center mark associated.").as_ref(), - ); - } else { - self.command_line.push_error( - crate::t!("CENTERREASSOCIATE: the selected source is not circular.") - .as_ref(), - ); - } - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.refresh_properties(); - } - CmdResult::EditDimensionBreak { - dimensions, - operation, - } => { - self.push_undo_snapshot(i, "DIMBREAK"); - let result = apply_dimbreak(&mut self.tabs[i].scene, &dimensions, operation); - if result.changed.is_empty() { - self.command_line.push_info(&result.message); - } else { - for handle in &result.changed { - self.tabs[i].scene.invalidate_dim_block_recorded(*handle); - } - let changes = result - .changed - .iter() - .copied() - .map(|handle| (handle, crate::scene::ChangeKind::Modified)) - .collect::>(); - self.tabs[i].scene.bump_entities(&changes); - self.tabs[i].dirty = true; - self.command_line.push_output(&result.message); - } - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - } - CmdResult::EditDimensionJog { dimension, point } => { - let valid = matches!( - self.tabs[i].scene.document.get_entity(dimension), - Some(acadrust::EntityType::Dimension( - acadrust::entities::Dimension::Linear(_) - | acadrust::entities::Dimension::Aligned(_) - )) - ); - if !valid { - self.command_line.push_error( - crate::t!("DIMJOGLINE: select a linear or aligned dimension.").as_ref(), - ); - } else if !self.reject_locked_edit(i, dimension) { - self.push_undo_snapshot(i, "DIMJOGLINE"); - let values = point.map(|point| { - use acadrust::xdata::XDataValue; - vec![ - XDataValue::Integer16(387), - XDataValue::Integer16(3), - XDataValue::Integer16(389), - XDataValue::Point3D(acadrust::types::Vector3::new( - point.x, point.y, point.z, - )), - ] - }); - crate::scene::view::dispatch::set_entity_xdata( - &mut self.tabs[i].scene.document, - dimension, - "ACAD_DSTYLE_DIMJAG_POSITION", - values, - ); - self.tabs[i].scene.invalidate_dim_block_recorded(dimension); - self.tabs[i] - .scene - .bump_entities(&[(dimension, crate::scene::ChangeKind::Modified)]); - self.tabs[i].dirty = true; - self.command_line.push_output( - if point.is_some() { - crate::t!("DIMJOGLINE: jog added.") - } else { - crate::t!("DIMJOGLINE: jog removed.") - } - .as_ref(), - ); - } - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - } - CmdResult::SpaceDimensions { - base, - others, - spacing, - } => { - self.push_undo_snapshot(i, "DIMSPACE"); - if apply_dimspace(&mut self.tabs[i].scene, base, &others, spacing) { - self.tabs[i].dirty = true; - self.command_line - .push_output(crate::t!("DIMSPACE Spacing adjusted.").as_ref()); - } - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - } - CmdResult::ReplaceEntity(handle, new_entities) => { - if self.reject_locked_edit(i, handle) { - return Task::none(); - } - // Detect SPLINEDIT sentinel: a single XLine with a magic layer name. - if new_entities.len() == 1 { - if let acadrust::EntityType::XLine(ref xl) = new_entities[0] { - let op = xl.common.layer.clone(); - if op.starts_with("__SPLINEDIT_") { - let label = self.history_label_from_active_cmd(i, "SPLINEDIT"); - self.push_undo_snapshot(i, label); - crate::modules::draw::modify::splinedit::apply_spline_op( - &mut self.tabs[i].scene.document, - handle, - &op, - ); - self.tabs[i].dirty = true; - let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); - if let Some(p) = prompt { - self.command_line.push_info(&p); - } - return Task::none(); - } - } - } - let label = self.history_label_from_active_cmd(i, "TRIM"); - self.push_undo_snapshot(i, label); - self.tabs[i].scene.erase_entities(&[handle]); - let new_handles: Vec = new_entities - .into_iter() - .map(|e| self.tabs[i].scene.add_entity(e)) - .collect(); - // Rebuild replaced dimensions from edited data. - for &nh in &new_handles { - if matches!( - self.tabs[i].scene.document.get_entity(nh), - Some(acadrust::EntityType::Dimension(_)) - ) { - self.tabs[i].scene.invalidate_dim_block_recorded(nh); - } - } - let pedit_entities = if self.tabs[i] - .active_cmd - .as_ref() - .is_some_and(|command| command.name() == "PEDIT") - { - new_handles - .iter() - .filter_map(|new| self.tabs[i].scene.document.get_entity(*new).cloned()) - .collect::>() - } else { - Vec::new() - }; - if let Some(cmd) = &mut self.tabs[i].active_cmd { - cmd.on_entity_replaced(handle, &new_handles); - for entity in pedit_entities { - cmd.inject_picked_entity(entity); - } - } - self.tabs[i].dirty = true; - let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); - if let Some(p) = prompt { - self.command_line.push_info(&p); - } - } - CmdResult::AttreqNeeded { block_name } => { - // Collect the full AttributeDefinitions owned by this block - // record so each created attribute keeps its geometry (#255). - let attdefs: Vec = { - let doc = &self.tabs[i].scene.document; - if let Some(br) = doc.block_records.get(&block_name) { - br.entity_handles - .iter() - .filter_map(|&h| { - if let Some(acadrust::EntityType::AttributeDefinition(ad)) = - doc.get_entity(h) - { - Some(ad.clone()) - } else { - None - } - }) - .collect() - } else { - vec![] - } - }; - - if attdefs.is_empty() { - // No attribute definitions — commit the INSERT directly. - let entity = self.tabs[i] - .active_cmd - .as_mut() - .and_then(|c| c.attreq_take_insert()); - if let Some(entity) = entity { - let insert_name = match &entity { - acadrust::EntityType::Insert(ins) => Some(ins.block_name.clone()), - _ => None, - }; - let label = self.history_label_from_active_cmd(i, "INSERT"); - let delta_safe = self.delta_add_safe(i, &entity); - let pending = self.begin_undo(i, label, 1, delta_safe); - self.commit_entity(entity); - self.tabs[i].dirty = true; - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - if let Some(n) = insert_name { - self.record_block_insert(&n); - } - self.restore_pre_cmd_tangent(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - } else { - let completed = self.tabs[i] - .active_cmd - .as_mut() - .and_then(|cmd| cmd.attreq_set_attdefs(attdefs)); - if let Some(entity) = completed { - return self.apply_cmd_result(CmdResult::CommitAndExit(entity)); - } - let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); - if let Some(p) = prompt { - self.command_line.push_info(&p); - } - } - } - CmdResult::CommitLiveEntity(entity) => { - let label = self.history_label_from_active_cmd(i, "ENTITY"); - let delta_safe = self.delta_add_safe(i, &entity); - let pending = self.begin_undo(i, label, 1, delta_safe); - let handle = self.commit_entity_handle(entity); - self.tabs[i].dirty = true; - self.tabs[i].scene.clear_preview_wire(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - if let Some(h) = handle { - // Keep the live document Arc unique while later vertices - // replace the geometry in place. The final Arc is captured - // by UpdateLiveEntity when the command completes. - self.defer_live_entity_history_after(i, h); - if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { - cmd.set_live_handle(h); - } - } - let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); - if let Some(p) = prompt { - self.command_line.push_info(&p); - } - } - CmdResult::UpdateLiveEntity { - handle, - entity, - finish, - } => { - let tracks_draw_anchor = matches!( - &entity, - acadrust::EntityType::Line(_) - | acadrust::EntityType::Arc(_) - | acadrust::EntityType::LwPolyline(_) - | acadrust::EntityType::Polyline(_) - | acadrust::EntityType::Polyline2D(_) - | acadrust::EntityType::Polyline3D(_) - ); - // Replace the live entity's geometry in place, preserving its - // handle and layer (the fresh entity from the command carries - // defaults — a NULL handle would desync it from the document - // map key and drop it from rendering / hit-test). No undo - // snapshot — the create already pushed one, so the whole object - // reverts as a unit. - if let Some(old) = self.tabs[i].scene.document.get_entity_mut(handle) { - // The initial live commit assigns the handle, owning block, - // current layer and common display properties. Geometry - // refreshes must retain all of that identity; replacing only - // the handle and layer reset owner_handle to NULL and made - // the completed entity unavailable to scoped operations such - // as parametric constraints. - let common = old.common().clone(); - let mut new = entity; - *new.common_mut() = common; - *old = new; - self.tabs[i] - .scene - .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); - self.tabs[i].dirty = true; - } - if tracks_draw_anchor { - self.tabs[i].last_draw_anchor = Some(handle); - } - if finish { - self.finish_live_entity_history(i, handle); - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - } else { - let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); - if let Some(p) = prompt { - self.command_line.push_info(&p); - } - } - } - CmdResult::FinalizeLiveEntity(handle) => { - // The live geometry already matches the command's committed - // vertices. Close the deferred history image and UI state - // without publishing a redundant Modified delta. - self.finish_live_entity_history(i, handle); - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - } - CmdResult::RemoveLiveEntity(handle) => { - // The command backed off below a valid entity (PLINE Undo at - // one remaining vertex): take the live entity out of the - // document, drop its provisional history entry and keep - // prompting. A later second point creates one fresh entry. - self.tabs[i].scene.erase_entities(&[handle]); - if self.tabs[i] - .last_draw_anchor - .is_some_and(|anchor_handle| anchor_handle == handle) - { - self.tabs[i].last_draw_anchor = None; - } - self.discard_last_undo_entry(i); - self.tabs[i].dirty = true; - let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); - if let Some(p) = prompt { - self.command_line.push_info(&p); - } - } - cancel @ (CmdResult::Cancel | CmdResult::CancelForSpaceChange) => { - let space_changed = matches!(cancel, CmdResult::CancelForSpaceChange); - self.tabs[i].scene.clear_preview_wire(); - if !space_changed && self.tabs[i].suspended_cmd.is_some() { - self.tabs[i].active_cmd = self.tabs[i].suspended_cmd.take(); - let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); - if let Some(p) = prompt { - self.command_line.push_info(&p); - } - let opts = self.tabs[i] - .active_cmd - .as_ref() - .map(|c| c.options()) - .unwrap_or_default(); - self.command_line.set_step_options(opts); - self.refresh_active_cmd_preview(i); - } else { - self.tabs[i].suspended_cmd = None; - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - self.command_line.push_info( - crate::t!(if space_changed { - "Command cancelled because the active drawing space changed." - } else { - "Command cancelled." - }) - .as_ref(), - ); - } - } - CmdResult::SelectByPath { - path, - closed, - crossing, - } => { - // The command picked the path; the hit test lives here, where - // the camera and the drawing's geometry are. Everything after - // matches what a lasso does, Remove included. - let canvas = self.tabs[i].scene.selection.borrow().vp_size; - let bounds = iced::Rectangle { - x: 0.0, - y: 0.0, - width: canvas.0, - height: canvas.1, - }; - let edit_cam = self.tabs[i] - .scene - .viewport_edit_frame(canvas) - .map(|(cam, _)| cam); - let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds); - let screen: Vec = path - .iter() - .map(|p| { - crate::scene::pick::hit_test::world_to_screen( - glam::DVec3::new(p[0], p[1], 0.0), - view_rot, - eye, - bounds, - ) - }) - .collect(); - let handles = self.tabs[i].scene.path_hit_handles( - &screen, - crossing, - !closed, - all_wires, - view_rot, - eye, - bounds, - |point| self.cursor_model_point(i, &edit_cam, point, bounds), - ); - if self.select_remove_mode { - for h in &handles { - self.tabs[i].scene.deselect_entity(*h); - } - } else { - for h in &handles { - self.tabs[i].scene.select_entity(*h, false); - } - self.tabs[i].scene.expand_selection_for_groups(&handles); - } - self.refresh_properties(); - let selected: Vec = self.tabs[i] - .scene - .selected_entities() - .into_iter() - .map(|(h, _)| h) - .collect(); - let count = handles.len(); - self.command_line - .push_info(crate::tf!("{count} object(s) found.").as_ref()); - return self.feed_command(StepInput::SelectionComplete(selected)); - } - CmdResult::Relaunch(cmd, handles) => { - self.tabs[i].scene.deselect_all(); - for h in &handles { - self.tabs[i].scene.select_entity(*h, false); - } - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - dispatched = self.dispatch_command(&cmd); - } - CmdResult::Dispatch(cmd) => { - // End this interactive front-end, then run the assembled command - // through the normal dispatcher. Selection is left untouched. - // The option picked in DIMCONSTRAINT becomes its next default; - // the same command run on its own leaves the default alone. - let from_menu = self.tabs[i] - .active_cmd - .as_ref() - .is_some_and(|command| command.name() == "DIMCONSTRAINT"); - if from_menu { - match cmd.as_str() { - "DCRADIUS" => self.dim_constraint_last = "Radius", - "DCDIAMETER" => self.dim_constraint_last = "Diameter", - "DCCONVERT" => self.dim_constraint_last = "Convert", - _ => {} - } - } - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - dispatched = self.dispatch_command(&cmd); - } - CmdResult::EditTableCell { handle, point } => { - // TABLEDIT's pick: end the pick phase and hand (table, point) - // to the shared cell-edit launcher. A miss or a locked cell - // re-prompts until a valid table cell is selected. - use crate::modules::annotate::table_cmd::{table_cell_at, TableCellEditStart}; - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - - if handle == acadrust::Handle::NULL && self.selection_cycling { - let mut candidates = Vec::new(); - for entity in self.tabs[i].scene.document.entities() { - if let acadrust::EntityType::Table(table) = entity { - let h = table.common.handle; - let style = table.table_style_handle.and_then(|style_handle| { - self.tabs[i] - .scene - .document - .objects - .get(&style_handle) - .and_then(|object| match object { - acadrust::objects::ObjectType::TableStyle(style) => { - Some(style) - } - _ => None, - }) - }); - if table_cell_at(table, style, point).is_some() { - candidates.push(h); - } - } - } - if candidates.len() >= 2 { - self.cycle_candidates = Some((self.quick_properties_anchor, candidates)); - return Task::none(); - } - } - - match self.begin_table_cell_edit(i, handle, point) { - TableCellEditStart::Started => {} - TableCellEditStart::LockedCell => { - self.command_line - .push_error(crate::t!("The cell is content locked.").as_ref()); - let _ = self.dispatch_command("TABLEDIT"); - } - TableCellEditStart::NoCell => { - self.command_line - .push_error(crate::t!("No editable table cell picked.").as_ref()); - let _ = self.dispatch_command("TABLEDIT"); - } - } - } - CmdResult::MatchEntityLayer { dest, src } => { - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - let src_layer = self.tabs[i] - .scene - .document - .get_entity(src) - .map(|e| e.common().layer.clone()); - let dest: Vec<_> = dest - .into_iter() - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - if dest.is_empty() { - self.command_line - .push_info(crate::t!("No editable objects selected.").as_ref()); - } else if let Some(layer) = src_layer { - self.push_undo_snapshot(i, "LAYMATCH"); - for h in &dest { - if let Some(e) = self.tabs[i].scene.document.get_entity_mut(*h) { - e.as_entity_mut().set_layer(layer.clone()); - } - } - // New layer changes the baked by-layer colour/linetype/ - // lineweight — re-tessellate the moved entities so they - // repaint immediately (issue #231 class). - self.invalidate_property_targets(i, &dest); - self.tabs[i].dirty = true; - self.command_line - .push_info(crate::tf!("Layer matched to \"{layer}\".").as_ref()); - self.sync_ribbon_layers(); - } else { - self.command_line - .push_error(crate::t!("Source object not found.").as_ref()); - } - } - CmdResult::MatchProperties { mut dest, src } => { - dest.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); - if dest.is_empty() { - return Task::none(); - } - // The command stays active after each apply so more targets - // can keep being picked; Enter / Esc ends it (#362). - let src_clone = self.tabs[i].scene.document.get_entity(src).cloned(); - let src_common = src_clone.as_ref().map(|e| e.common().clone()); - let thickness = src_clone - .as_ref() - .and_then(crate::scene::view::dispatch::entity_thickness); - // This application record is the hatch background colour. - // Keep the outer Option to distinguish "source is not a - // hatch" from "source hatch has no background". - let hatch_background_xdata: Option>> = - match src_clone.as_ref() { - Some(acadrust::EntityType::Hatch(h)) => Some( - h.common - .extended_data - .get_record("HATCHBACKGROUNDCOLOR") - .map(|r| r.values.clone()), - ), - _ => None, - }; - // Dimension-style overrides ride the ACAD record, identified - // by a leading DSTYLE string. Matching replicates that payload - // (or clears the destination when the source has none). - let dstyle_xdata: Option> = src_clone - .as_ref() - .filter(|e| { - matches!( - e, - acadrust::EntityType::Dimension(_) | acadrust::EntityType::Leader(_) - ) - }) - .map(|e| crate::entities::dim_override::pairs(&e.common().extended_data)); - - if let Some(common) = src_common { - self.apply_property_op(i, "MATCHPROP", &dest, |app, handle| { - let mut is_dim = false; - let mut is_hatch = false; - if let Some(e) = app.tabs[i].scene.document.get_entity_mut(handle) { - e.as_entity_mut().set_layer(common.layer.clone()); - crate::scene::view::dispatch::apply_color(e, common.color); - crate::scene::view::dispatch::apply_line_weight(e, common.line_weight); - { - let dst_common = e.common_mut(); - dst_common.linetype = common.linetype.clone(); - dst_common.linetype_handle = common.linetype_handle; - dst_common.linetype_scale = common.linetype_scale; - dst_common.transparency = common.transparency.clone(); - dst_common.color_name = common.color_name.clone(); - dst_common.color_book_handle = common.color_book_handle; - dst_common.full_visual_style_handle = - common.full_visual_style_handle; - dst_common.face_visual_style_handle = - common.face_visual_style_handle; - dst_common.edge_visual_style_handle = - common.edge_visual_style_handle; - dst_common.material_flags = common.material_flags; - dst_common.material_handle = common.material_handle; - dst_common.shadow_flags = common.shadow_flags; - dst_common.plotstyle_flags = common.plotstyle_flags; - dst_common.plotstyle_handle = common.plotstyle_handle; - } - if let Some(value) = thickness { - crate::scene::view::dispatch::set_entity_thickness(e, value); - } - if let Some(se) = &src_clone { - is_dim = matches!(e, acadrust::EntityType::Dimension(_)); - is_hatch = matches!(e, acadrust::EntityType::Hatch(_)); - match_special_props(se, e); - } - } - if is_hatch { - if let Some(values) = &hatch_background_xdata { - crate::scene::view::dispatch::set_entity_xdata( - &mut app.tabs[i].scene.document, - handle, - "HATCHBACKGROUNDCOLOR", - values.clone(), - ); - } - } - // Dim-style overrides follow the style for dimension / - // leader destinations — through set_entity_xdata so no - // stale raw record survives. - if dstyle_xdata.is_some() - || matches!( - app.tabs[i].scene.document.get_entity(handle), - Some( - acadrust::EntityType::Dimension(_) - | acadrust::EntityType::Leader(_) - ) - ) - { - if matches!( - app.tabs[i].scene.document.get_entity(handle), - Some( - acadrust::EntityType::Dimension(_) - | acadrust::EntityType::Leader(_) - ) - ) && matches!( - src_clone, - Some( - acadrust::EntityType::Dimension(_) - | acadrust::EntityType::Leader(_) - ) - ) { - crate::entities::dim_override::replace( - &mut app.tabs[i].scene.document, - handle, - dstyle_xdata.clone().unwrap_or_default(), - ); - } - } - // A restyled dimension renders from its baked *D block — - // drop the stale block so the new style shows (#398). - if is_dim { - app.tabs[i].scene.invalidate_dim_block_recorded(handle); - } - // Hatch fills render from a prebuilt model (#415). - app.tabs[i].scene.refresh_fill_model(handle); - }); - // Color / linetype / lineweight are baked into the cached - // wires at tessellation time; re-tessellate only the matched - // objects instead of rebuilding a large drawing. - let changes: Vec<_> = dest - .iter() - .copied() - .map(|handle| (handle, crate::scene::ChangeKind::Modified)) - .collect(); - self.tabs[i].scene.bump_entities(&changes); - self.command_line.push_info( - crate::tf!("Properties matched to {} object(s).", dest.len()).as_ref(), - ); - // Clear the consumed target selection and keep prompting. - self.tabs[i].scene.deselect_all(); - if let Some(cmd) = &self.tabs[i].active_cmd { - self.command_line.push_info(&cmd.prompt()); - } - } else { - self.command_line - .push_error(crate::t!("Source object not found.").as_ref()); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - } - } - CmdResult::PasteClipboard { base_pt } => { - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - if self.clipboard.is_empty() { - self.command_line - .push_error(crate::t!("Clipboard is empty.").as_ref()); - } else { - let delta = base_pt - self.clipboard_base; - let translate = crate::command::EntityTransform::Translate(delta); - self.push_undo_snapshot(i, "PASTECLIP"); - let count = self.clipboard.len(); - let by_index = self.finalize_paste(i, Some(translate)); - self.tabs[i].scene.deselect_all(); - for h in by_index.iter().copied().filter(|h| !h.is_null()) { - self.tabs[i].scene.select_entity(h, false); - } - self.tabs[i].dirty = true; - // Surface any layers the paste brought in (cross-drawing) - // in the layer manager and the layer dropdown. - self.refresh_layer_panel(); - self.refresh_properties(); - self.command_line - .push_info(crate::tf!("{count} object(s) pasted.").as_ref()); - } - } - CmdResult::CreateGroup { mut handles, name } => { - handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - if handles.is_empty() { - return Task::none(); - } - let undo = self.begin_group_undo(i, "GROUP"); - self.tabs[i].scene.create_group(name.clone(), handles); - self.tabs[i].dirty = true; - self.commit_group_undo(i, undo); - self.command_line - .push_info(crate::tf!("Group \"{}\" created.", name).as_ref()); - } - CmdResult::DeleteGroups { mut handles } => { - handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - if handles.is_empty() { - return Task::none(); - } - let undo = self.begin_group_undo(i, "UNGROUP"); - let count = self.tabs[i].scene.delete_groups_containing(&handles); - self.tabs[i].dirty = true; - self.commit_group_undo(i, undo); - if count > 0 { - self.command_line - .push_info(crate::tf!("{} group(s) dissolved.", count).as_ref()); - } else { - self.command_line - .push_info(crate::t!("No groups found for selected objects.").as_ref()); - } - } - CmdResult::VpLayerUpdate { - vp_handle, - freeze, - thaw, - } => { - // Resolve layer names → handles, then update frozen_layers on the viewport(s). - // vp_handle == Handle::NULL means "apply to all viewports in current layout". - let freeze_handles: Vec = freeze - .iter() - .filter_map(|name| { - self.tabs[i] - .scene - .document - .layers - .iter() - .find(|l| l.name.eq_ignore_ascii_case(name)) - .map(|l| l.handle) - }) - .collect(); - let thaw_handles: Vec = thaw - .iter() - .filter_map(|name| { - self.tabs[i] - .scene - .document - .layers - .iter() - .find(|l| l.name.eq_ignore_ascii_case(name)) - .map(|l| l.handle) - }) - .collect(); - - let mut frozen_count = 0usize; - let mut thawed_count = 0usize; - - // Collect target viewport handles - let target_handles: Vec = if vp_handle == acadrust::Handle::NULL { - // All viewports in current layout block - let block_handle = self.tabs[i].scene.current_layout_block_handle_pub(); - self.tabs[i] - .scene - .document - .entities() - .filter(|e| { - e.common().owner_handle == block_handle - && matches!(e, acadrust::EntityType::Viewport(_)) - }) - .map(|e| e.common().handle) - .collect() - } else { - vec![vp_handle] - }; - - for &target_handle in &target_handles { - if let Some(acadrust::EntityType::Viewport(vp)) = - self.tabs[i].scene.document.get_entity_mut(target_handle) - { - for h in &freeze_handles { - if !vp.frozen_layers.contains(h) { - vp.frozen_layers.push(*h); - frozen_count += 1; - } - } - for h in &thaw_handles { - let before = vp.frozen_layers.len(); - vp.frozen_layers.retain(|fh| fh != h); - if vp.frozen_layers.len() < before { - thawed_count += 1; - } - } - } - } - - if frozen_count > 0 || thawed_count > 0 { - self.push_undo_snapshot(i, "VPLAYER"); - self.tabs[i].dirty = true; - if frozen_count > 0 { - self.command_line.push_info( - crate::tf!("VPLAYER: {frozen_count} layer(s) frozen in viewport.") - .as_ref(), - ); - } - if thawed_count > 0 { - self.command_line.push_info( - crate::tf!("VPLAYER: {thawed_count} layer(s) thawed in viewport.") - .as_ref(), - ); - } - // Sync layer panel so VP freeze columns update immediately. - let doc_layers = self.tabs[i].scene.document.layers.clone(); - let vp_info = self.tabs[i].scene.viewport_list(); - self.tabs[i] - .layers - .sync_with_viewports(&doc_layers, vp_info); - } - - // Show updated prompt (command stays active for more operations). - let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); - if let Some(p) = prompt { - self.command_line.push_info(&p); - } - } - - CmdResult::ZoomToWindow { p1, p2 } => { - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].scene.remember_current_view(); - self.tabs[i] - .scene - .zoom_to_window(p1.as_vec3(), p2.as_vec3()); - self.command_line - .push_output(crate::t!("Zoom Window").as_ref()); - } - CmdResult::Measurement(msg) => { - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - self.command_line.push_output(&msg); - } - CmdResult::ReportMeasurement(msg) => { - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.refresh_area_preview(i); - self.command_line.push_output(&msg); - if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()) { - self.command_line.push_info(&prompt); - } - } - CmdResult::ReportError(msg) => { - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.command_line.push_error(&msg); - if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()) { - self.command_line.push_info(&prompt); - } - } - CmdResult::ReportMeasurementAndDeselect(msg) => { - self.tabs[i].snap_result = None; - self.tabs[i].scene.deselect_all(); - self.tabs[i].scene.clear_preview_wire(); - self.refresh_area_preview(i); - self.refresh_properties(); - self.command_line.push_output(&msg); - if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()) { - self.command_line.push_info(&prompt); - } - } - CmdResult::DeselectAndContinue => { - self.tabs[i].snap_result = None; - self.tabs[i].scene.deselect_all(); - self.tabs[i].scene.clear_preview_wire(); - self.refresh_area_preview(i); - self.refresh_properties(); - if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()) { - self.command_line.push_info(&prompt); - } - } - CmdResult::AlignSelected { - mut handles, - src1, - dst1, - angle_rad, - scale, - } => { - handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); - if handles.is_empty() { - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - } else { - let label = self.history_label_from_active_cmd(i, "ALIGN"); - // A chain of transform_entities on the same handles — the - // recording keeps each entity's first (pre-align) image, so - // it's delta-safe like a single transform. - let pending = self.begin_undo(i, label, handles.len(), true); - // Step 1: translate so src1 is at origin - self.tabs[i].scene.transform_entities( - &handles, - &crate::command::EntityTransform::Translate(-src1), - ); - // Step 2: uniform scale (only when != 1) - if (scale - 1.0).abs() > 1e-4 { - self.tabs[i].scene.transform_entities( - &handles, - &crate::command::EntityTransform::Scale { - center: glam::DVec3::ZERO, - factor: scale, - }, - ); - } - // Step 3: rotate in the XY plane by angle_rad - if angle_rad.abs() > 1e-4 { - self.tabs[i].scene.transform_entities( - &handles, - &crate::command::EntityTransform::Rotate { - center: glam::DVec3::ZERO, - axis: glam::DVec3::Z, - angle_rad, - }, - ); - } - // Step 4: translate to dst1 - self.tabs[i].scene.transform_entities( - &handles, - &crate::command::EntityTransform::Translate(dst1), - ); - self.tabs[i].dirty = true; - self.tabs[i].scene.deselect_all(); - for h in &handles { - self.tabs[i].scene.select_entity(*h, false); - } - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - self.command_line - .push_output(crate::t!("ALIGN: applied.").as_ref()); - self.refresh_properties(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } - } - CmdResult::LengthenEntity { - handle, - pick_pt, - mode, - } => { - if self.reject_locked_edit(i, handle) { - return Task::none(); - } - use crate::modules::draw::modify::lengthen::lengthen_entity; - let result = self.tabs[i] - .scene - .document - .get_entity(handle) - .and_then(|e| lengthen_entity(e, pick_pt.as_vec3(), &mode)); - match result { - Some(new_entity) => { - let label = self.history_label_from_active_cmd(i, "LENGTHEN"); - self.push_undo_snapshot(i, label); - self.tabs[i].scene.erase_entities(&[handle]); - self.tabs[i].scene.add_entity(new_entity); - self.tabs[i].dirty = true; - self.command_line - .push_output(crate::t!("LENGTHEN: applied.").as_ref()); - self.refresh_properties(); - } - None => { - self.command_line - .push_error(crate::t!("LENGTHEN: entity type not supported.").as_ref()); - } - } - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - } - CmdResult::DivideEntity { handle, n, marker } => { - use crate::modules::draw::inquiry::divide::divide_entity; - let pts = self.tabs[i] - .scene - .document - .get_entity(handle) - .map(|e| divide_entity(e, n, marker.as_ref())) - .unwrap_or_default(); - let count = pts.len(); - if count > 0 { - self.push_undo_snapshot(i, "DIVIDE"); - let layer = self.tabs[i].active_layer.clone(); - for mut p in pts { - p.as_entity_mut().set_layer(layer.clone()); - self.tabs[i].scene.add_entity(p); - } - self.tabs[i].dirty = true; - self.command_line - .push_output(crate::tf!("DIVIDE: {count} marker(s) placed.").as_ref()); - } else { - self.command_line.push_error( - crate::t!("DIVIDE: entity type not supported or N < 2.").as_ref(), - ); - } - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - } - CmdResult::MeasureEntity { - handle, - segment_length, - pick_point, - marker, - } => { - use crate::modules::draw::inquiry::divide::measure_entity; - let pts = self.tabs[i] - .scene - .document - .get_entity(handle) - .map(|e| measure_entity(e, segment_length, pick_point, marker.as_ref())) - .unwrap_or_default(); - let count = pts.len(); - if count > 0 { - self.push_undo_snapshot(i, "MEASURE"); - let layer = self.tabs[i].active_layer.clone(); - for mut p in pts { - p.as_entity_mut().set_layer(layer.clone()); - self.tabs[i].scene.add_entity(p); - } - self.tabs[i].dirty = true; - self.command_line - .push_output(crate::tf!("MEASURE: {count} marker(s) placed.").as_ref()); - } else { - self.command_line - .push_output(crate::t!("MEASURE: 0 markers placed.").as_ref()); - } - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - } - CmdResult::PeditOp { handle, op } => return self.apply_pedit_result(i, handle, op), - CmdResult::JoinToSource { source, handles } => { - if self.reject_locked_edit(i, source) { - return Task::none(); - } - let joined = self.tabs[i] - .scene - .document - .get_entity(source) - .and_then(|entity| { - let candidates: Vec<_> = handles - .iter() - .filter(|handle| { - **handle != source && !self.tabs[i].scene.is_layer_locked(**handle) - }) - .filter_map(|handle| { - self.tabs[i] - .scene - .document - .get_entity(*handle) - .map(|entity| (*handle, entity)) - }) - .collect(); - crate::modules::draw::modify::join::join_to_source(entity, &candidates) - }); - if let Some((replacement, consumed)) = joined { - self.push_undo_snapshot(i, "JOIN"); - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(source) { - *entity = replacement; - } - self.tabs[i].scene.erase_entities(&consumed); - self.tabs[i].scene.refresh_fill_model(source); - self.tabs[i] - .scene - .bump_entities(&[(source, crate::scene::ChangeKind::Modified)]); - self.tabs[i].dirty = true; - self.command_line.push_output(&format!( - "JOIN: {} objects joined to source.", - consumed.len() - )); - self.refresh_properties(); - } else { - self.command_line - .push_info("JOIN: no compatible objects joined to source."); - } - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - } - CmdResult::JoinEntities(handles) => { - if let Some(handle) = handles - .iter() - .find(|handle| self.tabs[i].scene.is_layer_locked(**handle)) - { - self.reject_locked_edit(i, *handle); - return Task::none(); - } - use crate::modules::draw::modify::join::join_entities; - let pairs: Vec<_> = handles - .iter() - .filter_map(|&h| self.tabs[i].scene.document.get_entity(h).map(|e| (h, e))) - .collect(); - match join_entities(&pairs) { - Some((to_remove, merged)) => { - let label = self.history_label_from_active_cmd(i, "JOIN"); - self.push_undo_snapshot(i, label); - self.tabs[i].scene.erase_entities(&to_remove); - let count_in = to_remove.len(); - let count_out = merged.len(); - for e in merged { - self.tabs[i].scene.add_entity(e); - } - self.tabs[i].dirty = true; - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - self.command_line.push_output( - crate::tf!("JOIN: {count_in} object(s) joined into {count_out}.") - .as_ref(), - ); - self.refresh_properties(); - } - None => { - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - self.command_line.push_error( - crate::t!("JOIN: objects don't form a single connected chain, or contain an unsupported type / tilted arc.").as_ref(), - ); - } - } - } - CmdResult::BreakEntity { handle, p1, p2 } => { - if self.reject_locked_edit(i, handle) { - return Task::none(); - } - use crate::modules::draw::modify::break_cmd::break_entity; - let replacement = self.tabs[i] - .scene - .document - .get_entity(handle) - .and_then(|e| break_entity(e, p1, p2)); - match replacement { - Some(frags) => { - let unchanged = frags.len() == 1 - && self.tabs[i].scene.document.get_entity(handle).is_some_and( - |original| { - let mut fragment = frags[0].clone(); - fragment.common_mut().handle = handle; - &fragment == original - }, - ); - if unchanged { - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - self.command_line.push_output("BREAK: no geometry changed."); - return Task::none(); - } - let label = self.history_label_from_active_cmd(i, "BREAK"); - self.push_undo_snapshot(i, label); - let count = frags.len(); - let owner = self.tabs[i] - .scene - .document - .get_entity(handle) - .map(|entity| entity.common().owner_handle); - let mut fragments = frags.into_iter(); - if let Some(mut first) = fragments.next() { - first.common_mut().handle = handle; - self.tabs[i].scene.update_entity(first); - for mut fragment in fragments { - fragment.common_mut().handle = Handle::NULL; - if let Some(owner) = owner { - fragment.common_mut().owner_handle = owner; - } - self.tabs[i].scene.add_entity(fragment); - } - } else { - self.tabs[i].scene.erase_entities(&[handle]); - } - self.tabs[i].dirty = true; - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - self.command_line - .push_output(crate::tf!("BREAK: {} fragment(s).", count).as_ref()); - self.refresh_properties(); - } - None => { - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - self.command_line - .push_error(crate::t!("BREAK: entity type not supported.").as_ref()); - } - } - } - CmdResult::SetPlotWindow { p1, p2 } => { - let layout_name = self.tabs[i].scene.current_layout.clone(); - if layout_name == "Model" { - // Model space: remember the window (world X/Y) for the plot dialog. - let x0 = p1.x.min(p2.x); - let y0 = p1.y.min(p2.y); - let x1 = p1.x.max(p2.x); - let y1 = p1.y.max(p2.y); - self.plot_window = Some((x0, y0, x1, y1)); - self.plot_dialog.window = self.plot_window; - self.command_line.push_output( - crate::tf!("Plot window: {x0:.2},{y0:.2} to {x1:.2},{y1:.2}").as_ref(), - ); - // Pick window closed the plot dialog so the viewport could - // receive the two clicks — bring the dialog back with the - // window now active. - self.plot_dialog.area = "Window".to_string(); - // Remember the pick immediately, like the printer choice. - self.save_config(); - self.active_modal = Some(super::ModalKind::Plot); - } else { - // PLOTWINDOW always describes the plotted layout. In MSPACE - // the command points are model coordinates, so map them back - // through the active floating viewport first. - let p1 = self.tabs[i].scene.model_to_paper(p1); - let p2 = self.tabs[i].scene.model_to_paper(p2); - let x1 = p1.x.min(p2.x); - let y1 = p1.y.min(p2.y); - let x2 = p1.x.max(p2.x); - let y2 = p1.y.max(p2.y); - self.plot_window = Some((x1, y1, x2, y2)); - self.plot_dialog.window = self.plot_window; - self.command_line.push_output( - crate::tf!("Plot window: {x1:.2},{y1:.2} to {x2:.2},{y2:.2}").as_ref(), - ); - self.plot_dialog.area = "Window".to_string(); - self.save_config(); - self.active_modal = Some(super::ModalKind::Plot); - } - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - } - CmdResult::QuickPrint(handles) => { - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - return self.on_quick_print_handles(handles); - } - CmdResult::StretchWindow { - mut handles, - windows, - } => { - // Accumulate every entity touched by any crossing window. Keep STRETCH - // in its selection stage; Enter is what advances to the base point. - { - let scene = &self.tabs[i].scene; - - for (win_min, win_max) in &windows { - handles.extend( - scene - .interaction_handles_in_world_aabb([ - win_min.x, win_min.y, win_max.x, win_max.y, - ]) - .into_iter() - .filter(|&handle| !scene.is_layer_locked(handle)), - ); - } - } - - handles.sort_unstable_by_key(|handle| handle.value()); - handles.dedup(); - - if handles.is_empty() { - self.command_line - .push_output(crate::t!("STRETCH: nothing crosses the window.").as_ref()); - } else { - // Visual feedback must only highlight entities that actually have a - // stretchable point inside one of the crossing windows. `handles` can - // contain the whole candidate/preselection set, while `windows` decides - // which vertices will really move. - let point_inside = |point: glam::DVec3| { - const EPS: f64 = 1.0e-9; - - windows.iter().any(|(win_min, win_max)| { - point.x >= win_min.x - EPS - && point.x <= win_max.x + EPS - && point.y >= win_min.y - EPS - && point.y <= win_max.y + EPS - && point.z >= win_min.z - EPS - && point.z <= win_max.z + EPS - }) - }; - - let mut visual_handles = Vec::new(); - - for &handle in &handles { - let wires = self.tabs[i].scene.wire_models_for(&[handle]); - - let affected = wires.iter().any(|wire| { - // LINE / LWPOLYLINE / other vertex-based entities. - let vertex_hit = wire - .key_vertices - .iter() - .any(|point| point_inside(glam::DVec3::from_array(*point))); - - if vertex_hit { - return true; - } - - // Whole-object STRETCH cases such as circles/arcs/inserts/points: - // use only their meaningful anchor snap, not quadrants or - // tessellated display points. - wire.snap_pts.iter().any(|(point, hint)| { - matches!( - hint, - crate::scene::model::wire_model::SnapHint::Center - | crate::scene::model::wire_model::SnapHint::Insertion - | crate::scene::model::wire_model::SnapHint::Node - ) && point_inside(*point) - }) - }); - - if affected { - visual_handles.push(handle); - } - } - - self.tabs[i].scene.deselect_all(); - self.tabs[i].scene.select_entities(&visual_handles); - self.refresh_selected_grips(); - // STRETCH temporary grips: keep only the actual vertices/endpoints - // that lie inside one of the accumulated crossing windows. - // - // Mid-segment grips are deliberately excluded: STRETCH moves vertices, - // not the midpoint affordances used by normal grip editing. - let mut stretch_grips = Vec::new(); - let mut stretch_grip_handles = Vec::new(); - - for (handle, grip) in std::mem::take(&mut self.tabs[i].selected_grip_handles) - .into_iter() - .zip(std::mem::take(&mut self.tabs[i].selected_grips)) - { - if !grip.is_midpoint && point_inside(grip.world) { - stretch_grip_handles.push(handle); - stretch_grips.push(grip); - } - } - - self.tabs[i].selected_grip_handles = stretch_grip_handles; - self.tabs[i].selected_grips = stretch_grips; - } - - use crate::command::CadCommand; - use crate::modules::draw::modify::stretch::StretchCommand; - - let wires = self.tabs[i].scene.wire_models_for(&handles); - - let cmd = StretchCommand::with_windows(handles, wires, windows); - - self.command_line.push_info(&CadCommand::prompt(&cmd)); - self.tabs[i].active_cmd = Some(Box::new(cmd)); - } - CmdResult::StretchEntities { - mut handles, - windows, - delta, - } => { - handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); - if handles.is_empty() { - self.tabs[i].active_cmd = None; - return Task::none(); - } - let structural = handles.iter().any(|handle| { - matches!( - self.tabs[i].scene.document.get_entity(*handle), - Some(acadrust::EntityType::Dimension(_)) - ) - }); - let pending = self.begin_undo(i, "STRETCH", handles.len(), !structural); - let mut count = 0usize; - let mut changed_handles = Vec::new(); - let mut driven_refs = Vec::new(); - - // Helper: is DXF point (x, y) inside the world-space window? - // Drawing plane is world XY (= DXF XY). - let in_win = |x: f64, y: f64| -> bool { - windows.iter().any(|(win_min, win_max)| { - x >= win_min.x && x <= win_max.x && y >= win_min.y && y <= win_max.y - }) - }; - - let dx = delta.x as f64; - let dy = delta.y as f64; // drawing plane is world XY - let dz = delta.z as f64; - - // Dimensions whose points moved — their baked *D block is - // stale afterwards and must be dropped (see #398 / #372). - let mut stretched_dims: Vec = Vec::new(); - for handle in &handles { - let before = self.tabs[i].scene.document.get_entity_arc(*handle); - if let Some(entity) = before.as_deref() { - use crate::scene::parametric_constraints::ParametricRef; - match entity { - acadrust::EntityType::Line(line) => { - if in_win(line.start.x, line.start.y) { - driven_refs.push(ParametricRef::point(*handle, 0)); - } - if in_win(line.end.x, line.end.y) { - driven_refs.push(ParametricRef::point(*handle, 1)); - } - } - acadrust::EntityType::LwPolyline(polyline) => { - if let Some(world) = - crate::entities::curve::lwpolyline_world_xy(polyline) - { - for (index, vertex) in world.vertices.iter().enumerate() { - if in_win(vertex.location.x, vertex.location.y) { - driven_refs.push(ParametricRef::point( - *handle, - index as i32, - )); - } - } - } - } - acadrust::EntityType::Polyline2D(polyline) => { - for (index, vertex) in polyline.vertices.iter().enumerate() { - if in_win(vertex.location.x, vertex.location.y) { - driven_refs.push(ParametricRef::point( - *handle, - index as i32, - )); - } - } - } - acadrust::EntityType::Circle(circle) - if in_win(circle.center.x, circle.center.y) => - { - driven_refs.push(ParametricRef::center(*handle)); - } - acadrust::EntityType::Arc(arc) - if in_win(arc.center.x, arc.center.y) => - { - driven_refs.push(ParametricRef::center(*handle)); - } - _ => {} - } - } - let Some(entity) = self.tabs[i].scene.document.get_entity_mut(*handle) else { - continue; - }; - let mut stretched = false; - match entity { - acadrust::EntityType::Line(l) => { - let s_in = in_win(l.start.x, l.start.y); - let e_in = in_win(l.end.x, l.end.y); - if s_in { - l.start.x += dx; - l.start.y += dy; - l.start.z += dz; - stretched = true; - } - if e_in { - l.end.x += dx; - l.end.y += dy; - l.end.z += dz; - stretched = true; - } - } - acadrust::EntityType::LwPolyline(p) => { - let Some(mut world) = crate::entities::curve::lwpolyline_world_xy(p) - else { - continue; - }; - for v in &mut world.vertices { - if in_win(v.location.x, v.location.y) { - v.location.x += dx; - v.location.y += dy; - stretched = true; - } - } - if stretched { - *p = world; - } - } - acadrust::EntityType::Polyline2D(p) => { - for v in &mut p.vertices { - if in_win(v.location.x, v.location.y) { - v.location.x += dx; - v.location.y += dy; - stretched = true; - } - } - } - acadrust::EntityType::Polyline(p) => { - for v in &mut p.vertices { - if in_win(v.location.x, v.location.z) { - v.location.x += dx; - v.location.z += dy; - stretched = true; - } - } - } - acadrust::EntityType::Arc(a) => { - if in_win(a.center.x, a.center.y) { - a.center.x += dx; - a.center.y += dy; - a.center.z += dz; - stretched = true; - } - } - acadrust::EntityType::Circle(c) => { - if in_win(c.center.x, c.center.y) { - c.center.x += dx; - c.center.y += dy; - c.center.z += dz; - stretched = true; - } - } - acadrust::EntityType::Ellipse(e) => { - if in_win(e.center.x, e.center.y) { - e.center.x += dx; - e.center.y += dy; - e.center.z += dz; - stretched = true; - } - } - acadrust::EntityType::Insert(ins) => { - if in_win(ins.insert_point.x, ins.insert_point.y) { - ins.insert_point.x += dx; - ins.insert_point.y += dy; - ins.insert_point.z += dz; - stretched = true; - } - } - acadrust::EntityType::Text(t) => { - if in_win(t.insertion_point.x, t.insertion_point.y) { - t.insertion_point.x += dx; - t.insertion_point.y += dy; - t.insertion_point.z += dz; - stretched = true; - } - } - acadrust::EntityType::MText(t) => { - if in_win(t.insertion_point.x, t.insertion_point.y) { - t.insertion_point.x += dx; - t.insertion_point.y += dy; - t.insertion_point.z += dz; - stretched = true; - } - } - acadrust::EntityType::Viewport(vp) => { - stretched = windows.iter().any(|(win_min, win_max)| { - crate::entities::viewport::stretch(vp, *win_min, *win_max, delta) - }); - } - acadrust::EntityType::Dimension(dim) => { - use acadrust::entities::Dimension; - // Move every definition point that falls inside the - // window — the same points the grips expose — then - // refresh the stored measurement so the value tracks - // the stretched geometry. - let mv = |p: &mut acadrust::types::Vector3| { - if in_win(p.x, p.y) { - p.x += dx; - p.y += dy; - p.z += dz; - true - } else { - false - } - }; - match dim { - Dimension::Linear(d) => { - stretched |= mv(&mut d.first_point); - stretched |= mv(&mut d.second_point); - stretched |= mv(&mut d.definition_point); - } - Dimension::Aligned(d) => { - stretched |= mv(&mut d.first_point); - stretched |= mv(&mut d.second_point); - stretched |= mv(&mut d.definition_point); - } - Dimension::Radius(d) => { - stretched |= mv(&mut d.angle_vertex); - stretched |= mv(&mut d.definition_point); - } - Dimension::Diameter(d) => { - stretched |= mv(&mut d.angle_vertex); - stretched |= mv(&mut d.definition_point); - } - Dimension::Angular2Ln(d) => { - stretched |= mv(&mut d.angle_vertex); - stretched |= mv(&mut d.first_point); - stretched |= mv(&mut d.second_point); - stretched |= mv(&mut d.definition_point); - } - Dimension::Angular3Pt(d) => { - stretched |= mv(&mut d.angle_vertex); - stretched |= mv(&mut d.first_point); - stretched |= mv(&mut d.second_point); - stretched |= mv(&mut d.definition_point); - } - Dimension::Ordinate(d) => { - stretched |= mv(&mut d.feature_location); - stretched |= mv(&mut d.leader_endpoint); - } - Dimension::Arc(d) => { - stretched |= mv(&mut d.definition_point); - stretched |= mv(&mut d.first_extension_point); - stretched |= mv(&mut d.second_extension_point); - stretched |= mv(&mut d.center_point); - if d.has_leader { - stretched |= mv(&mut d.first_leader_point); - stretched |= mv(&mut d.second_leader_point); - } - } - Dimension::LargeRadial(d) => { - stretched |= mv(&mut d.definition_point); - stretched |= mv(&mut d.chord_point); - stretched |= mv(&mut d.override_center); - stretched |= mv(&mut d.jog_point); - } - } - // Pinned text follows too; the zero sentinel means - // "auto placement" and must not be captured by a - // window that happens to cover the origin. - let t = dim.base().text_middle_point; - if t.x * t.x + t.y * t.y + t.z * t.z > 1e-16 { - let mut t = t; - if mv(&mut t) { - dim.base_mut().text_middle_point = t; - stretched = true; - } - } - if stretched { - dim.base_mut().actual_measurement = dim.measurement(); - stretched_dims.push(*handle); - } - } - _ => { - // Generic: move entire entity (treat as block-level) - stretched = false; // skip generic types - } - } - if stretched { - if let Some(before) = before { - self.tabs[i].scene.record_undo_before(*handle, Some(before)); - } - self.tabs[i].scene.mark_entity_dirty(*handle); - changed_handles.push(*handle); - count += 1; - } - } - // A stretched dimension renders through its baked *D block when - // one exists (file roundtrip) — drop it so tessellation falls - // back to the live points and the next save re-bakes. (#372) - for h in stretched_dims { - self.tabs[i].scene.invalidate_dim_block_recorded(h); - } - - // Geometry was edited in place via get_entity_mut, which the - // scene's tessellation cache doesn't observe. Re-tessellate the - // moved entities so the viewport reflects the stretch right away - // instead of only on the next unrelated redraw. See #95. - if count > 0 { - let changes: Vec<_> = changed_handles - .iter() - .map(|&handle| (handle, crate::scene::ChangeKind::Modified)) - .collect(); - self.tabs[i].scene.bump_entities_with_parametric_policy( - &changes, - &driven_refs, - self.constraint_solve_mode && !driven_refs.is_empty(), - ); - self.apply_inferred_constraints(i, &changed_handles); - } - self.tabs[i].dirty = true; - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - self.command_line - .push_output(crate::tf!("STRETCH: {count} entity(ies) stretched.").as_ref()); - self.refresh_properties(); - if let Some(pending) = pending { - self.commit_undo_delta(i, pending); - } - } - // ── EXTRUDE ──────────────────────────────────────────────────── - CmdResult::ExtrudeEntities { - handles, - extent, - mode, - taper_angle, - color: _, - } => { - let delete_sources = self.delete_objects != 0; - if handles.is_empty() - || handles - .iter() - .any(|handle| self.reject_locked_edit(i, *handle)) - { - self.tabs[i].active_cmd = None; - return Task::none(); - } - use crate::command::{ExtrudeExtent, ExtrudeMode}; - use crate::modules::insert::solid3d_cmds::{empty_extruded_surface, empty_solid3d}; - use crate::scene::model::sweep_model; - let path = match extent { - ExtrudeExtent::Path(handle) => self.tabs[i] - .scene - .document - .get_entity(handle) - .cloned() - .map(|entity| (handle, entity)), - _ => None, - }; - if matches!(extent, ExtrudeExtent::Path(_)) && path.is_none() { - self.command_line - .push_error(crate::t!("EXTRUDE: path entity not found.").as_ref()); - self.tabs[i].active_cmd = None; - return Task::none(); - } - let pending = self.begin_undo(i, "EXTRUDE", handles.len(), true); - let mut created_handles = Vec::new(); - let mut consumed = Vec::new(); - let mut failed = 0usize; - for handle in &handles { - let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() - else { - failed += 1; - continue; - }; - let Some((profile, closed)) = sweep_model::extrusion_profile_of(&entity) else { - failed += 1; - continue; - }; - let direction = match extent { - ExtrudeExtent::Height(height) => profile.plane.normal().map(|normal| { - glam::DVec3::new( - normal[0] * height, - normal[1] * height, - normal[2] * height, - ) - }), - ExtrudeExtent::Direction(direction) => Some(direction), - ExtrudeExtent::Path(_) => None, - }; - let path_direction = path - .as_ref() - .and_then(|(_, path)| sweep_model::straight_path_direction(path)) - .map(glam::DVec3::from_array); - // The creation mode controls closed profiles. Open curves - // always create sheet surfaces. - let creates_surface = matches!(mode, ExtrudeMode::Surface) || !closed; - let body = match (creates_surface, &path, direction) { - (false, Some((_, path)), _) => { - sweep_model::extruded_along_path(&entity, path, taper_angle) - } - (false, None, Some(direction)) => sweep_model::extruded_direction( - &entity, - direction.to_array(), - taper_angle, - ), - (true, None, Some(direction)) => sweep_model::extruded_surface( - &entity, - direction.to_array(), - taper_angle, - ), - (true, Some(_), _) => path_direction.and_then(|direction| { - sweep_model::extruded_surface( - &entity, - direction.to_array(), - taper_angle, - ) - }), - _ => None, - }; - let Some(body) = body else { - failed += 1; - continue; - }; - let created = if creates_surface { - let direction = direction.or(path_direction).unwrap_or(glam::DVec3::ZERO); - let history = path - .is_none() - .then(|| { - sweep_model::extrusion_history( - &entity, - None, - direction.to_array(), - taper_angle, - profile.plane.origin, - ) - }) - .flatten(); - let mut surface = empty_extruded_surface(direction, taper_angle); - if let ( - Some(acadrust::objects::SolidHistoryOperation::Extrusion(value)), - acadrust::EntityType::Surface(entity), - ) = (&history, &mut surface) - { - if let Some(data) = - crate::scene::model::solid_history::extrusion_surface_data(value) - { - entity.surface_data = data; - } - } - match history { - Some(history) => { - self.add_surface_model_with_history(surface, body, history) - } - None => self.add_surface_model(surface, body), - } - } else { - let direction = direction.unwrap_or(glam::DVec3::ZERO); - let history = path - .as_ref() - .and_then(|(_, path)| { - sweep_model::sweep_history( - &entity, - path, - taper_angle, - profile.plane.origin, - ) - }) - .or_else(|| { - sweep_model::extrusion_history( - &entity, - None, - direction.to_array(), - taper_angle, - profile.plane.origin, - ) - }) - .unwrap_or_else(|| crate::scene::model::solid_history::brep_op(&body)); - self.add_solid_model(empty_solid3d(), body, history) - }; - if created.is_null() { - failed += 1; - } else { - created_handles.push(created); - if delete_sources { - consumed.push(*handle); - } - } - } - if delete_sources && !created_handles.is_empty() { - consumed.sort_unstable_by_key(|handle| handle.value()); - consumed.dedup(); - self.tabs[i].scene.erase_entities(&consumed); - } - if !created_handles.is_empty() { - self.tabs[i].scene.deselect_all(); - for handle in &created_handles { - self.tabs[i].scene.select_entity(*handle, true); - } - self.tabs[i].dirty = true; - self.command_line.push_output( - crate::tf!( - "EXTRUDE: created %{created} object(s); %{failed} source(s) could not be extruded.", - created = created_handles.len(), - failed = failed - ) - .as_ref(), - ); - if let Some(pending) = pending { - self.commit_undo_delta(i, pending); - } - } else { - self.command_line.push_error(crate::t!("EXTRUDE: no selected object could be extruded with the requested options.").as_ref()); - if let Some(pending) = pending { - self.commit_undo_delta(i, pending); - } - } - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - self.refresh_properties(); - } - - CmdResult::ThickenEntities { handles, distance } => { - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - - if handles.is_empty() { - self.command_line - .push_output(crate::t!("No surfaces selected.").as_ref()); - return Task::none(); - } - if !distance.is_finite() { - self.command_line - .push_error(crate::t!("Requires numeric distance or two points.").as_ref()); - return Task::none(); - } - if distance.abs() <= f64::EPSILON { - return Task::none(); - } - - use crate::modules::insert::solid3d_cmds::empty_solid3d; - let pending = self.begin_undo(i, "THICKEN", handles.len(), true); - let mut created = 0usize; - let mut failed = 0usize; - let mut self_intersections = 0usize; - for handle in handles { - let Some(acadrust::EntityType::Surface(surface)) = - self.tabs[i].scene.document.get_entity(handle) - else { - failed += 1; - continue; - }; - let kernel_distance = - if surface.kind == acadrust::entities::SurfaceKind::Revolved { - -distance - } else { - distance - }; - let body = self.tabs[i] - .scene - .solid_models - .get(&handle) - .cloned() - .or_else(|| { - crate::scene::convert::solid3d_tess::kernel_surface_body(surface) - }); - let Some(body) = body else { - failed += 1; - continue; - }; - let solid = match cadkernel::brep::thicken(&body, kernel_distance) { - Ok(solid) => solid, - Err(cadkernel::brep::ThickenError::SelfIntersection) => { - failed += 1; - self_intersections += 1; - continue; - } - Err(_) => { - failed += 1; - continue; - } - }; - let history = crate::scene::model::solid_history::brep_op(&solid); - if self - .add_solid_model(empty_solid3d(), solid, history) - .is_null() - { - failed += 1; - } else { - created += 1; - } - } - - if created > 0 { - self.tabs[i].dirty = true; - } - if self_intersections > 0 { - self.command_line.push_error(&crate::tf!( - "{} surface(s) cannot be thickened because the offset intersects itself.", - self_intersections - )); - } - if failed > 0 { - self.command_line.push_output(&crate::tf!( - "THICKEN: created {} solid(s); {} surface(s) could not be thickened exactly.", - created, failed - )); - } - if let Some(pending) = pending { - self.commit_undo_delta(i, pending); - } - self.refresh_properties(); - } - - CmdResult::PresspullPick { - handle, - point, - offset, - multiple: _, - } => { - self.presspull_pick(handle, point, offset); - } - CmdResult::PresspullApply { - targets, - distance, - color: _, - } => { - self.presspull_apply(targets, distance); - } - - // ── REVOLVE ──────────────────────────────────────────────────── - CmdResult::RevolveEntities { - handles, - axis_start, - axis_end, - angle, - start_angle, - mode, - color: _, - } => { - if handles.is_empty() { - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - return Task::none(); - } - let delete_sources = self.delete_objects != 0; - use crate::command::ExtrudeMode; - use crate::modules::insert::solid3d_cmds::{empty_revolved_surface, empty_solid3d}; - use crate::scene::model::sweep_model; - let from = axis_start.to_array(); - let to = axis_end.to_array(); - let mut editable_handles = Vec::with_capacity(handles.len()); - let mut failed = 0usize; - for handle in handles { - if self.reject_locked_edit(i, handle) { - failed += 1; - } else { - editable_handles.push(handle); - } - } - let pending = if editable_handles.is_empty() { - None - } else { - self.begin_undo(i, "REVOLVE", editable_handles.len(), true) - }; - let mut created_handles = Vec::new(); - let mut consumed = Vec::new(); - for handle in &editable_handles { - let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() - else { - failed += 1; - continue; - }; - let Some((_, closed)) = sweep_model::extrusion_profile_of(&entity) else { - failed += 1; - continue; - }; - let creates_surface = mode == ExtrudeMode::Surface || !closed; - let created = if creates_surface { - let Some(body) = - sweep_model::revolved_surface(&entity, from, to, angle, start_angle) - else { - failed += 1; - continue; - }; - self.add_surface_model( - empty_revolved_surface( - &entity, - axis_start, - axis_end, - angle, - start_angle, - ), - body, - ) - } else { - let result = - sweep_model::revolve_history(&entity, from, to, angle, start_angle) - .and_then(|history| { - cadkernel::acis::rebuild_body(&history) - .ok() - .map(|solid| (solid, history)) - }) - .or_else(|| { - sweep_model::revolved(&entity, from, to, angle, start_angle) - .map(|solid| { - let history = - crate::scene::model::solid_history::brep_op(&solid); - (solid, history) - }) - }); - let Some((solid, history)) = result else { - failed += 1; - continue; - }; - self.add_solid_model(empty_solid3d(), solid, history) - }; - if created.is_null() { - failed += 1; - } else { - created_handles.push(created); - if delete_sources { - consumed.push(*handle); - } - } - } - if delete_sources && !created_handles.is_empty() { - consumed.sort_unstable_by_key(|handle| handle.value()); - consumed.dedup(); - self.tabs[i].scene.erase_entities(&consumed); - } - if !created_handles.is_empty() { - self.tabs[i].scene.deselect_all(); - for handle in &created_handles { - self.tabs[i].scene.select_entity(*handle, true); - } - self.tabs[i].dirty = true; - self.command_line.push_output( - crate::tf!( - "REVOLVE: created %{created} object(s); %{failed} source(s) could not be revolved.", - created = created_handles.len(), - failed = failed - ) - .as_ref(), - ); - } else { - self.command_line.push_error( - crate::t!("REVOLVE: no selected object could be revolved with the requested options.") - .as_ref(), - ); - } - if let Some(pending) = pending { - self.commit_undo_delta(i, pending); - } - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - self.refresh_properties(); - } - // ── SWEEP ───────────────────────────────────────────────────── - CmdResult::SweepEntities { - handles, - path_handle, - mode, - options, - color: _, - } => { - use crate::command::ExtrudeMode; - use crate::modules::insert::solid3d_cmds::empty_solid3d; - use crate::scene::model::sweep_model; - let delete_objects = self.delete_objects; - let delete_profiles = delete_objects != 0; - let path = self.tabs[i].scene.document.get_entity(path_handle).cloned(); - let mut profiles = Vec::new(); - let mut failed = 0usize; - for handle in handles { - if handle == path_handle || self.reject_locked_edit(i, handle) { - failed += 1; - continue; - } - if let Some(entity) = self.tabs[i].scene.document.get_entity(handle).cloned() { - profiles.push((handle, entity)); - } else { - failed += 1; - } - } - let selection = profiles - .iter() - .map(|(_, entity)| entity.clone()) - .collect::>(); - let options = sweep_model::sweep_selection_options(&selection, options); - let pending = if profiles.is_empty() { - None - } else { - self.begin_undo(i, "SWEEP", profiles.len(), true) - }; - let mut created_handles = Vec::new(); - let mut consumed = Vec::new(); - for (handle, profile) in profiles { - let result = path.as_ref().zip(options).and_then(|(path, options)| { - let record = sweep_model::sweep_record(&profile, path, options)?; - let (_, _, closed) = cadkernel::acis::sweep_profile_geometry( - record.sweep_entity.as_ref()?, - record.sweep_entity_transform, - ) - .ok()?; - let surface = mode == ExtrudeMode::Surface || !closed; - let body = - cadkernel::acis::rebuild_sweep_with_mode(&record, surface).ok()?; - Some((body, record, surface)) - }); - let Some((body, record, surface)) = result else { - failed += 1; - continue; - }; - let deletes_path = - crate::app::delobj_deletes_auxiliary(delete_objects, surface); - if deletes_path && self.tabs[i].scene.is_layer_locked(path_handle) { - failed += 1; - continue; - } - let created = if surface { - self.add_surface_model(sweep_model::swept_surface_entity(&record), body) - } else { - self.add_solid_model( - empty_solid3d(), - body, - acadrust::objects::SolidHistoryOperation::Sweep(record), - ) - }; - if created.is_null() { - failed += 1; - } else { - created_handles.push(created); - if delete_profiles { - consumed.push(handle); - } - if deletes_path { - consumed.push(path_handle); - } - } - } - if !created_handles.is_empty() { - consumed.sort_unstable_by_key(|handle| handle.value()); - consumed.dedup(); - self.tabs[i].scene.erase_entities(&consumed); - self.tabs[i].scene.deselect_all(); - for handle in &created_handles { - self.tabs[i].scene.select_entity(*handle, true); - } - self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "SWEEP: created {created} object(s); {failed} source(s) could not be swept.", - created = created_handles.len(), failed = failed - ).as_ref()); - } else { - self.command_line.push_error( - crate::t!("SWEEP: could not sweep the profile along the path.").as_ref(), - ); - } - if let Some(pending) = pending { - self.commit_undo_delta(i, pending); - } - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - self.refresh_properties(); - } - - // ── LOFT ────────────────────────────────────────────────────── - CmdResult::LoftEntities { - sections, - guides, - path, - mode, - options, - color: _, - } => { - use crate::command::LoftSectionSelection; - use crate::modules::insert::solid3d_cmds::empty_solid3d; - use crate::scene::model::loft_command_model; - let mut sources = sections - .iter() - .flat_map(|section| match section { - LoftSectionSelection::Entity(handle) => vec![*handle], - LoftSectionSelection::Join(handles) => handles.clone(), - LoftSectionSelection::Point(_) => Vec::new(), - }) - .collect::>(); - sources.sort_unstable_by_key(|handle| handle.value()); - sources.dedup(); - let delete_objects = self.delete_objects; - let delete_sections = delete_objects != 0; - let section_locked = delete_sections - && sources - .iter() - .any(|handle| self.tabs[i].scene.is_layer_locked(*handle)); - let available = sources - .iter() - .chain(guides.iter()) - .copied() - .chain(path) - .filter_map(|handle| { - self.tabs[i] - .scene - .document - .get_entity(handle) - .cloned() - .map(|entity| (handle, entity)) - }) - .collect::>(); - let result = if section_locked { - Err("LOFT: a source is on a locked layer; disable source deletion or unlock it.".to_string()) - } else { - loft_command_model::record(§ions, &guides, path, &available, mode, options) - .and_then(|record| { - cadkernel::acis::rebuild_loft_with_options(&record) - .map(|body| (body, record)) - }) - }; - match result { - Ok((body, record)) => { - let surface = record - .parameters - .as_ref() - .is_some_and(|settings| settings.surface); - let delete_auxiliary = - crate::app::delobj_deletes_auxiliary(delete_objects, surface); - if delete_auxiliary - && guides - .iter() - .copied() - .chain(path) - .any(|handle| self.tabs[i].scene.is_layer_locked(handle)) - { - self.command_line.push_error( - "LOFT: a source is on a locked layer; disable source deletion or unlock it.", - ); - return Task::none(); - } - let dirty_before = self.tabs[i].dirty; - let pending = self.begin_undo(i, "LOFT", 1, true); - let created = if surface { - let handle = self.add_surface_model( - loft_command_model::surface_entity(&record), - body, - ); - if !handle.is_null() { - self.tabs[i].scene.create_solid_history( - handle, - acadrust::objects::SolidHistoryOperation::Loft(record), - ); - } - handle - } else { - self.add_solid_model( - empty_solid3d(), - body, - acadrust::objects::SolidHistoryOperation::Loft(record), - ) - }; - if created.is_null() { - // The creation helper already removed its provisional - // result. Discard its empty undo recording as well. - self.tabs[i].scene.take_undo_recording(); - self.tabs[i].dirty = dirty_before; - self.command_line.push_error(crate::t!("LOFT could not create a complete display. The source sections were preserved.").as_ref()); - return Task::none(); - } else { - let mut consumed = if delete_sections { - sources.clone() - } else { - Vec::new() - }; - if delete_auxiliary { - consumed.extend(guides.iter().copied()); - consumed.extend(path); - } - consumed.sort_unstable_by_key(|handle| handle.value()); - consumed.dedup(); - self.tabs[i].scene.erase_entities(&consumed); - self.tabs[i].scene.deselect_all(); - self.tabs[i].scene.select_entity(created, true); - self.tabs[i].dirty = true; - let message = if surface { - crate::t!("LOFT: surface created.") - } else { - crate::t!("LOFT: solid created.") - }; - self.command_line.push_output(message.as_ref()); - } - if let Some(pending) = pending { - self.commit_undo_delta(i, pending); - } - } - Err(error) => { - self.command_line.push_error(&error); - return Task::none(); - } - } - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - self.refresh_properties(); - } - - CmdResult::SolidEdgeBlend { - handle, - edges, - base_face, - value, - other_value, - fillet, - } => { - let task = - self.solid_edge_blend(handle, &edges, base_face, value, other_value, fillet); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - return task; - } - - CmdResult::SolidShell { - handle, - actions, - distance, - } => { - let task = self.solid_shell(handle, &actions, distance); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - return task; - } - - CmdResult::SolidSubtract { - bases, - cutters, - convert_meshes, - } => { - let task = self.solid_subtract(&bases, &cutters, convert_meshes); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - return task; - } - - CmdResult::SliceEntities { - targets, - plane, - keep_point, - } => { - let task = self.solid_slice(&targets, plane, keep_point); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - return task; - } - - CmdResult::SliceSurfaceEntities { - targets, - cutter, - keep_point, - } => { - let task = self.solid_slice_surface(&targets, *cutter, keep_point); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - return task; - } - - CmdResult::HatcheditApply { - handle, - name, - scale, - angle, - operation, - } => { - if self.reject_locked_edit(i, handle) { - return Task::none(); - } - if !matches!( - self.tabs[i].scene.document.get_entity(handle), - Some(acadrust::EntityType::Hatch(_)) - ) { - self.command_line - .push_error(crate::t!("HATCHEDIT: hatch entity not found.").as_ref()); - } else { - use crate::command::{CadCommand, HatchEditOperation}; - if matches!(&operation, HatchEditOperation::BeginAssociate) { - let associative = matches!(self.tabs[i].scene.document.get_entity(handle),Some(acadrust::EntityType::Hatch(h)) if h.is_associative); - if associative { - self.command_line - .push_info("HATCHEDIT: hatch is already associative."); - self.tabs[i].active_cmd = None; - } else { - let plane = match self.tabs[i].scene.document.get_entity(handle) { - Some(acadrust::EntityType::Hatch(h)) => { - let storage = - crate::entities::curve::ocs_plane(h.normal, h.elevation); - crate::command::WorkingPlane::new( - glam::DVec3::from_array(storage.origin), - glam::DVec3::from_array(storage.x_axis), - glam::DVec3::from_array(storage.y_axis), - ) - } - _ => self.tabs[i].ucs_xform().working_plane(), - }; - let mut sources = - self.tabs[i].scene.boundary_sources_on_plane(plane, 1e-6); - sources.remove(&handle); - let command=crate::modules::draw::draw::hatchedit::HatcheditCommand::for_association(handle,name,scale,angle,plane,sources); - self.tabs[i].scene.deselect_all(); - self.command_line.push_info(&command.prompt()); - self.tabs[i].active_cmd = Some(Box::new(command)); - } - return Task::none(); - } - if let HatchEditOperation::DrawOrderBoundary { above } = &operation { - let references: Vec<_> = - match self.tabs[i].scene.document.get_entity(handle) { - Some(acadrust::EntityType::Hatch(h)) => h - .paths - .iter() - .flat_map(|p| p.boundary_handles.iter()) - .filter(|h| { - self.tabs[i].scene.document.get_entity(**h).is_some() - }) - .map(|h| format!("{:X}", h.value())) - .collect(), - _ => Vec::new(), - }; - self.tabs[i].active_cmd = None; - if references.is_empty() { - self.command_line - .push_info("HATCHEDIT: no associated boundary objects."); - return Task::none(); - } - self.tabs[i].scene.deselect_all(); - self.tabs[i].scene.select_entity(handle, false); - let command = format!( - "DRAWORDER {} {}", - if *above { "ABOVE" } else { "UNDER" }, - references.join(" ") - ); - return self.dispatch_view(&command, i).unwrap_or_else(Task::none); - } - if matches!( - &operation, - HatchEditOperation::DrawOrderFront | HatchEditOperation::DrawOrderBack - ) { - let command = if matches!(&operation, HatchEditOperation::DrawOrderFront) { - "DRAWORDER FRONT" - } else { - "DRAWORDER BACK" - }; - self.tabs[i].scene.deselect_all(); - self.tabs[i].scene.select_entity(handle, false); - self.tabs[i].active_cmd = None; - return self.dispatch_view(command, i).unwrap_or_else(Task::none); - } - self.push_undo_snapshot(i, "HATCHEDIT"); - match operation { - HatchEditOperation::Appearance { - color, - layer, - transparency, - } => { - let layer = layer.map(|name| { - if name == "." { - self.tabs[i].active_layer.clone() - } else { - name - } - }); - if layer.as_ref().is_some_and(|name| { - self.tabs[i].scene.document.layers.get(name).is_none() - }) { - self.discard_last_undo_entry(i); - self.command_line.push_error("HATCHEDIT: layer not found."); - return Task::none(); - } - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) - { - let common = entity.common_mut(); - if let Some(value) = color { - common.color = value; - common.color_name = None; - common.color_book_handle = None; - } - if let Some(value) = layer { - common.layer = value; - } - if let Some(value) = transparency { - common.transparency = value; - } - } - self.tabs[i] - .scene - .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); - self.refresh_properties(); - } - HatchEditOperation::Update { - origin, - store_origin, - disassociate, - style, - annotative, - } => { - if store_origin { - if let Some((x, y)) = origin { - if !self.tabs[i].scene.document.set_hatch_origin([x, y]) { - self.discard_last_undo_entry(i); - self.command_line - .push_error("HATCHEDIT: cannot store default origin."); - return Task::none(); - } - } - } - if let Some(acadrust::EntityType::Hatch(hatch)) = - self.tabs[i].scene.document.get_entity_mut(handle) - { - if !name.is_empty() && name != hatch.pattern.name { - if let Some(entry) = - crate::scene::model::hatch_patterns::find(&name) - { - let mut pattern = - crate::scene::model::hatch_patterns::build_dxf_pattern( - entry, - ); - crate::entities::hatch::scale_pattern_geometry( - &mut pattern, - scale.max(1.0e-6) as f64, - ); - crate::entities::hatch::rotate_pattern_geometry( - &mut pattern, - (angle as f64).to_radians(), - ); - let origin = hatch.pattern_origin(); - crate::entities::hatch::translate_pattern_geometry( - &mut pattern, - origin.x, - origin.y, - ); - hatch.pattern = pattern; - hatch.is_solid = matches!( - entry.gpu, - crate::scene::model::hatch_model::HatchPattern::Solid - ); - hatch.pattern_type = - acadrust::entities::HatchPatternType::Predefined; - hatch.gradient_color.enabled = false; - } - } else { - let requested_scale = scale.max(1.0e-6) as f64; - if hatch.pattern_scale > 1.0e-12 { - let factor = requested_scale / hatch.pattern_scale; - hatch.scale_pattern_about_origin(factor); - } - let requested_angle = (angle as f64).to_radians(); - let delta = requested_angle - hatch.pattern_angle; - hatch.rotate_pattern_about_origin(delta); - } - hatch.pattern_scale = scale.max(1.0e-6) as f64; - hatch.pattern_angle = (angle as f64).to_radians(); - if let Some((x, y)) = origin { - hatch.set_pattern_origin(acadrust::types::Vector2::new(x, y)); - } - if disassociate { - for path in &mut hatch.paths { - path.boundary_handles.clear(); - path.flags.set_external(false); - } - hatch.is_associative = false; - } - if let Some(style) = style { - hatch.style = style; - } - } - if let Some(value) = annotative { - crate::scene::annotative::set_entity_annotative( - &mut self.tabs[i].scene.document, - handle, - value, - ); - if value { - if let Some(scale_handle) = - self.tabs[i].scene.creation_annotation_scale_handle() - { - crate::scene::annotative::create_annotation_context( - &mut self.tabs[i].scene.document, - handle, - scale_handle, - ); - } - } - } - self.tabs[i] - .scene - .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); - } - HatchEditOperation::AddBoundaries(handles) => { - self.tabs[i] - .scene - .edit_hatch_boundary_handles(handle, &handles, true); - } - HatchEditOperation::RemoveBoundaries(handles) => { - self.tabs[i] - .scene - .edit_hatch_boundary_handles(handle, &handles, false); - } - HatchEditOperation::AssociatePaths(paths) => { - if paths.is_empty() - || paths.iter().any(|path| { - path.boundary_handles.is_empty() - || path.boundary_handles.iter().any(|source| { - self.tabs[i] - .scene - .document - .get_entity(*source) - .is_none() - }) - }) - { - self.discard_last_undo_entry(i); - self.command_line.push_error( - "HATCHEDIT: associated boundary is no longer available.", - ); - return Task::none(); - } - self.tabs[i].scene.replace_hatch_association(handle, paths); - } - HatchEditOperation::RecreateBoundary { associate, region } => { - let source = self.tabs[i].scene.document.get_entity(handle).cloned(); - if let Some(acadrust::EntityType::Hatch(source)) = source { - let storage = crate::entities::curve::ocs_plane( - source.normal, - source.elevation, - ); - let plane = crate::command::WorkingPlane::new( - glam::DVec3::from_array(storage.origin), - glam::DVec3::from_array(storage.x_axis), - glam::DVec3::from_array(storage.y_axis), - ); - let (entities, path_groups) = if region { - let loops = source - .paths - .iter() - .map(|path| { - path.edges - .iter() - .map(crate::entities::hatch::edge_curve) - .collect::>>() - }) - .collect::>>(); - let Some(loops) = loops.filter(|loops| { - !loops.is_empty() - && loops.iter().all(|ring| !ring.is_empty()) - }) else { - self.discard_last_undo_entry(i); - self.command_line.push_error( - "HATCHEDIT: boundary cannot form a region.", - ); - return Task::none(); - }; - let contained = loops - .iter() - .enumerate() - .map(|(inner, ring)| { - let seed = ring[0].point_at(0.0); - loops - .iter() - .enumerate() - .map(|(outer, boundary)| { - inner != outer - && cadkernel::geom2d::contains( - boundary, - seed, - cadkernel::geom2d::Tolerance::new(1e-6), - ) - }) - .collect::>() - }) - .collect::>(); - let depths = contained - .iter() - .map(|row| row.iter().filter(|inside| **inside).count()) - .collect::>(); - let groups = depths - .iter() - .enumerate() - .filter(|(_, depth)| **depth % 2 == 0) - .map(|(outer, depth)| { - let mut indices = vec![outer]; - indices.extend((0..loops.len()).filter(|inner| { - depths[*inner] == *depth + 1 - && contained[*inner][outer] - })); - indices - }) - .collect::>(); - let entities = groups - .iter() - .map(|indices| { - let profiles = indices - .iter() - .map(|index| loops[*index].clone()) - .collect::>(); - crate::scene::model::presspull_model::region_from_loops( - &profiles, plane, - ) - }) - .collect::>>(); - let Some(entities) = entities else { - self.discard_last_undo_entry(i); - self.command_line.push_error( - "HATCHEDIT: boundary cannot form a region.", - ); - return Task::none(); - }; - (entities, groups) - } else { - let rings = crate::scene::hatch_boundary_rings(&source); - if rings.len() != source.paths.len() { - self.discard_last_undo_entry(i); - self.command_line.push_error( - "HATCHEDIT: boundary cannot form a polyline.", - ); - return Task::none(); - } - let entities = crate::scene::boundary_entities(&rings, plane); - if entities.len() != source.paths.len() { - self.discard_last_undo_entry(i); - self.command_line.push_error( - "HATCHEDIT: boundary cannot form a polyline.", - ); - return Task::none(); - } - let groups = - (0..entities.len()).map(|index| vec![index]).collect(); - (entities, groups) - }; - let mut path_handles = vec![None; source.paths.len()]; - for (entity, paths) in entities.into_iter().zip(path_groups) { - if let Some(boundary) = self.commit_entity_handle(entity) { - for path in paths { - if let Some(slot) = path_handles.get_mut(path) { - *slot = Some(boundary); - } - } - } - } - if associate { - if let Some(acadrust::EntityType::Hatch(hatch)) = - self.tabs[i].scene.document.get_entity_mut(handle) - { - for (path, boundary) in - hatch.paths.iter_mut().zip(path_handles.iter().copied()) - { - if let Some(boundary) = boundary { - path.boundary_handles = vec![boundary]; - path.flags.set_external(true); - } - } - hatch.is_associative = - path_handles.iter().all(Option::is_some); - } - } - self.tabs[i] - .scene - .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); - } - } - HatchEditOperation::Separate => { - let source = self.tabs[i].scene.document.get_entity(handle).cloned(); - if let Some(acadrust::EntityType::Hatch(hatch)) = source { - let groups = crate::scene::separated_hatch_path_groups(&hatch); - if groups.len() > 1 { - for paths in groups { - let mut separated = hatch.clone(); - separated.common.handle = acadrust::Handle::NULL; - separated.paths = paths; - separated.is_associative = separated - .paths - .iter() - .any(|path| !path.boundary_handles.is_empty()); - self.tabs[i] - .scene - .add_entity(acadrust::EntityType::Hatch(separated)); - } - self.tabs[i].scene.erase_entities(&[handle]); - } - } - } - HatchEditOperation::DrawOrderFront - | HatchEditOperation::DrawOrderBack - | HatchEditOperation::DrawOrderBoundary { .. } - | HatchEditOperation::BeginAssociate => unreachable!(), - } - self.tabs[i].dirty = true; - self.command_line - .push_output(crate::t!("HATCHEDIT: hatch updated.").as_ref()); - } - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - } - CmdResult::OpenMTextEditor { - pos, - handle, - initial, - height, - template, - } => { - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.open_mtext_editor(pos, handle, &initial, height, template.map(|m| *m)); - } - CmdResult::SuspendForMTextInput { - pos, - initial, - height, - } => { - self.tabs[i].suspended_cmd = self.tabs[i].active_cmd.take(); - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - self.command_mtext_input = true; - self.pending_command_editor_text = None; - self.open_mtext_editor(pos, None, &initial, height, None); - } - CmdResult::OpenTextEditor { - pos, - handle, - initial, - height, - } => { - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.open_text_inline( - pos, - handle, - &initial, - height, - super::text_inline::TextEntityField::Text, - None, - ); - } - CmdResult::SuspendForTextInput { pos, entity } => { - self.tabs[i].suspended_cmd = self.tabs[i].active_cmd.take(); - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - let height = entity.height; - self.open_text_inline( - pos, - None, - "", - height, - super::text_inline::TextEntityField::Text, - Some(entity), - ); - } - CmdResult::EditTextEntity { handle } => { - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - self.ribbon.deactivate_tool(); - return self.begin_text_edit(handle); - } - CmdResult::SuspendForTextEdit { handle } => { - let is_editable = - crate::app::text_inline::can_edit_text(handle, &self.tabs[i].scene.document); - if !is_editable { - self.command_line - .push_error(crate::t!("TEXTEDIT: selected entity is not text.").as_ref()); - let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); - if let Some(p) = prompt { - self.command_line.push_info(&p); - } - return Task::none(); - } - let cmd = self.tabs[i].active_cmd.take(); - self.tabs[i].suspended_cmd = cmd; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - self.ribbon.deactivate_tool(); - return self.begin_text_edit(handle); - } - CmdResult::UndoDocument => { - let active = self.tabs[i].active_cmd.take(); - self.undo_active_tab(); - self.tabs[i].active_cmd = active; - if self.tabs[i] - .active_cmd - .as_ref() - .is_some_and(|command| command.name() == "PEDIT") - { - let entities: Vec<_> = - self.tabs[i].scene.document.entities().cloned().collect(); - if let Some(command) = self.tabs[i].active_cmd.as_mut() { - for entity in entities { - command.inject_picked_entity(entity); - } - } - } - { - let tab = &mut self.tabs[i]; - if let Some(command) = tab.active_cmd.as_mut() { - command.on_document_undone(&tab.scene.document); - } - } - let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); - if let Some(p) = prompt { - self.command_line.push_info(&p); - } - } - CmdResult::SetTexteditMode(val) => { - self.texteditmode = val; - let display_val = if val { 1 } else { 0 }; - self.command_line - .push_output(crate::tf!("TEXTEDITMODE set to {display_val}").as_ref()); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - } - CmdResult::EditDimensions { - mut handles, - operation, - } => { - handles.retain(|handle| { - !self.tabs[i].scene.is_layer_locked(*handle) - && matches!( - self.tabs[i].scene.document.get_entity(*handle), - Some(acadrust::EntityType::Dimension(_)) - ) - }); - if handles.is_empty() { - self.command_line.push_error( - crate::t!("DIMEDIT: no editable dimensions were selected.").as_ref(), - ); - } else { - self.push_undo_snapshot(i, "DIMEDIT"); - for handle in &handles { - if let Some(acadrust::EntityType::Dimension(dimension)) = - self.tabs[i].scene.document.get_entity_mut(*handle) - { - use crate::command::DimensionEditOperation as Operation; - match &operation { - Operation::Home => { - let base = dimension.base_mut(); - base.text_middle_point = acadrust::types::Vector3::ZERO; - base.insertion_point = acadrust::types::Vector3::ZERO; - base.text_user_positioned = false; - base.text_rotation = 0.0; - } - Operation::NewText(text) => { - dimension.base_mut().set_text_override( - (!text.is_empty()).then_some(text.clone()), - ); - } - Operation::Rotate(degrees) => { - dimension.base_mut().text_rotation = degrees.to_radians(); - } - Operation::Oblique(degrees) => match dimension { - acadrust::entities::Dimension::Linear(value) => { - value.ext_line_rotation = degrees.to_radians(); - } - acadrust::entities::Dimension::Aligned(value) => { - value.ext_line_rotation = degrees.to_radians(); - } - _ => {} - }, - } - } - self.tabs[i].scene.invalidate_dim_block_recorded(*handle); - } - let changes: Vec<_> = handles - .iter() - .copied() - .map(|handle| (handle, crate::scene::ChangeKind::Modified)) - .collect(); - self.tabs[i].scene.bump_entities(&changes); - self.tabs[i].dirty = true; - self.command_line.push_output( - crate::tf!("DIMEDIT: updated {} dimension(s).", handles.len()).as_ref(), - ); - } - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - } - CmdResult::DdeditEntity { handle, new_text } => { - if self.reject_locked_edit(i, handle) { - self.tabs[i].active_cmd = None; - return Task::none(); - } - self.push_undo_snapshot(i, "DDEDIT"); - let mut updated = false; - let mut is_dim = false; - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { - match entity { - acadrust::EntityType::Text(t) => { - t.value = new_text; - updated = true; - } - acadrust::EntityType::MText(t) => { - t.value = new_text; - updated = true; - } - acadrust::EntityType::AttributeDefinition(a) => { - a.default_value = new_text; - updated = true; - } - acadrust::EntityType::AttributeEntity(a) => { - a.set_value(new_text); - updated = true; - } - acadrust::EntityType::Dimension(d) => { - // Empty string resets to auto-measured value; otherwise set override. - let base = d.base_mut(); - base.text = new_text; - updated = true; - is_dim = true; - } - _ => {} - } - } - if is_dim { - // The edited override changed the dimension text; drop its - // stale *D block so save re-bakes it. (#181) - self.tabs[i].scene.invalidate_dim_block_recorded(handle); - } - if updated { - self.tabs[i].dirty = true; - self.command_line - .push_output(crate::t!("DDEDIT: text updated.").as_ref()); - } else { - self.discard_last_undo_entry(i); - self.command_line - .push_error(crate::t!("DDEDIT: entity type not supported.").as_ref()); - } - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - } - CmdResult::ReturnPoint(_) => { - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - } - } - // When no command is running the ribbon tool button still has to - // visually deactivate. Keyboard focus is assigned below to whichever - // editor currently owns typed input. - if self.tabs[i].active_cmd.is_none() { - self.ribbon.deactivate_tool(); - } - // The rich text canvas owns keyboard editing itself. Leaving the - // hidden command input focused would make it consume Left/Right before - // the editor can handle them. - let focus = if self.mtext_editor.is_some() { - self.unfocus_widgets() - } else if self.text_inline.is_some() { - // The in-place TEXT editor needs keyboard focus on its own field. - iced::widget::operation::focus(iced::widget::Id::new(super::view::TEXT_INLINE_ID)) - } else { - self.focus_cmd_input() - }; - Task::batch([dispatched, focus]) - } - - /// Restore the tangent-snap / ortho state that was in effect before the command started. - /// Recreate clipboard-dependency records (layer / linetype / text + dim - /// style) in tab `i`'s document for any the copied entities reference but - /// this drawing doesn't already have. Each recreated record gets a fresh - /// handle from the target document so it can't collide with an existing - /// one. No-op for same-document pastes (the records already exist). (#129) - pub(super) fn merge_dependencies(&mut self, i: usize, deps: &crate::app::ClipboardDeps) { - use acadrust::TableEntry; - if deps.is_empty() { - return; - } - let doc = &mut self.tabs[i].scene.document; - for rec in &deps.layers { - if !doc.layers.contains(rec.name()) { - let mut r = rec.clone(); - r.set_handle(doc.allocate_handle()); - let _ = doc.layers.add(r); - } - } - for rec in &deps.linetypes { - if !doc.line_types.contains(rec.name()) { - let mut r = rec.clone(); - r.set_handle(doc.allocate_handle()); - let _ = doc.line_types.add(r); - } - } - for rec in &deps.text_styles { - if !doc.text_styles.contains(rec.name()) { - let mut r = rec.clone(); - r.set_handle(doc.allocate_handle()); - let _ = doc.text_styles.add(r); - } - } - for rec in &deps.dim_styles { - if !doc.dim_styles.contains(rec.name()) { - let mut r = rec.clone(); - r.set_handle(doc.allocate_handle()); - let _ = doc.dim_styles.add(r); - } - } - } - - pub(super) fn merge_clipboard_deps(&mut self, i: usize) { - let deps = self.clipboard_deps.clone(); - self.merge_dependencies(i, &deps); - } - - /// Recreate any block definition the pasted INSERTs reference but tab - /// `i`'s document lacks (cross-drawing paste), so the block reference - /// renders its geometry instead of nothing. No-op for same-document - /// pastes. (#135) - pub(super) fn merge_clipboard_blocks(&mut self, i: usize) { - if self.clipboard_deps.blocks.is_empty() { - return; - } - let blocks = self.clipboard_deps.blocks.clone(); - for def in blocks { - if self.tabs[i] - .scene - .document - .block_records - .get(&def.name) - .is_some() - { - continue; - } - self.tabs[i] - .scene - .define_block_raw(&def.name, def.base_point, def.entities); - } - } - - /// Shared paste finalize for every paste path (PASTECLIP, PASTEORIG): - /// recreate the clipboard's dependency records and block definitions, add - /// each entity with fresh handles (optionally transformed), recreate each - /// entity's xdictionary graph (XCLIP filters etc.), and tessellate pasted - /// solids. Returns the new handles, index-aligned with the clipboard - /// (NULL where an add failed). Keeping this in one place means a new - /// cross-drawing concern is wired once, not re-implemented per command. - pub(super) fn finalize_paste( - &mut self, - i: usize, - translate: Option, - ) -> Vec { - self.merge_clipboard_deps(i); - self.merge_clipboard_blocks(i); - let by_index: Vec = self - .clipboard - .clone() - .into_iter() - .map(|mut entity| { - if let Some(t) = &translate { - crate::scene::view::dispatch::apply_transform(&mut entity, t); - } - // A dimension draws from its baked `*D` block (baked in WCS), so - // give the paste its own transformed copy of that block and - // re-point it — otherwise the pasted dimension renders at the - // source location instead of the paste point. The block was - // snapshotted into the clipboard at copy time, so this works - // cross-drawing too. Mirrors the in-drawing copy. (#290, #161) - if let acadrust::EntityType::Dimension(d) = &entity { - let bn = d.base().block_name.clone(); - if !bn.trim().is_empty() { - let subs = self - .clipboard_deps - .dim_blocks - .iter() - .find(|b| b.name.eq_ignore_ascii_case(&bn)) - .map(|def| def.entities.clone()); - if let Some(subs) = subs { - let bt = translate.clone().unwrap_or( - crate::command::EntityTransform::Translate(glam::DVec3::ZERO), - ); - if let Some(new_bn) = - self.tabs[i].scene.define_transformed_block(&subs, &bt) - { - if let acadrust::EntityType::Dimension(d) = &mut entity { - d.base_mut().block_name = new_bn; - } - } - } - } - } - self.tabs[i].scene.add_entity_clone(entity) - }) - .collect(); - let annotation_delta = match translate { - Some(crate::command::EntityTransform::Translate(delta)) => delta, - _ => glam::DVec3::ZERO, - }; - self.merge_clipboard_ext_objects(i, &by_index, annotation_delta); - // Source handles stored in the clipboard map one-to-one to the freshly - // pasted handles. Use that map to reconnect LEADER -> copied annotation. - let mut handle_map = rustc_hash::FxHashMap::default(); - - for (source, &copied) in self.clipboard.iter().zip(by_index.iter()) { - if !copied.is_null() { - handle_map.insert(source.common().handle, copied); - } - } - - let leader_links: Vec<(Handle, Handle)> = self - .clipboard - .iter() - .filter_map(|source| { - let acadrust::EntityType::Leader(leader) = source else { - return None; - }; - - let copied_leader = handle_map.get(&source.common().handle).copied()?; - let copied_annotation = handle_map - .get(&leader.annotation_handle) - .copied() - .unwrap_or(Handle::NULL); - - Some((copied_leader, copied_annotation)) - }) - .collect(); - - for (leader_handle, annotation_handle) in leader_links { - if let Some(acadrust::EntityType::Leader(leader)) = - self.tabs[i].scene.document.get_entity_mut(leader_handle) - { - leader.annotation_handle = annotation_handle; - } - - let _ = self.tabs[i] - .scene - .sync_displayed_annotation_context(leader_handle); - } - // Recreate any group whose whole membership was copied, so a pasted - // group stays grouped — cross-drawing too, since the groups were - // snapshotted into the clipboard at copy time. `by_index` is aligned - // with `self.clipboard`, so the source handle of each pasted entity maps - // its clipboard clone to its new handle. Same shared `recreate_groups` - // the in-drawing COPY path uses. (#440) - if !self.clipboard_deps.groups.is_empty() { - let groups = self.clipboard_deps.groups.clone(); - self.tabs[i].scene.recreate_groups(groups, &handle_map); - } - // Constraints wholly within the pasted selection follow it. - self.tabs[i] - .scene - .duplicate_parametric_constraints_for(&handle_map); - // Incremental: `add_entity` already tessellated every pasted top-level - // solid, and existing document solids are still cached — so only newly - // introduced block-definition solids need building. The full rebuild - // would clear and re-tessellate the entire document (every solid in the - // drawing) on each paste, which is what made a large paste stall. - self.tabs[i].scene.populate_missing_meshes_from_document(); - by_index - } - - /// Recreate the extension-dictionary object graph (XCLIP spatial filters, - /// attached XRecords, …) captured for each copied entity, cloning every - /// object into this document with fresh handles, remapping all internal - /// references, and re-pointing the pasted entity's `xdictionary_handle` at - /// the new root. `by_index` is the paste's new entity handles, aligned with - /// the clipboard order (NULL where the add failed). No-op without captures. - pub(super) fn merge_clipboard_ext_objects( - &mut self, - i: usize, - by_index: &[Handle], - annotation_delta: glam::DVec3, - ) { - if self.clipboard_deps.ext_objects.is_empty() { - return; - } - let captures = self.clipboard_deps.ext_objects.clone(); - let doc = &mut self.tabs[i].scene.document; - for cap in &captures { - let Some(&new_entity) = by_index.get(cap.entity_index) else { - continue; - }; - if new_entity.is_null() { - continue; - } - if let Some(new_root) = recreate_ext_subtree(doc, cap, Some(new_entity)) { - if let Some(e) = doc.get_entity_mut(new_entity) { - e.common_mut().xdictionary_handle = Some(new_root); - } - crate::scene::annotative::translate_annotation_contexts( - doc, - new_entity, - annotation_delta, - ); - } - } - // The wires were tessellated before the filters existed; refresh only - // the freshly-pasted entities whose clip object graph was attached. - let changes: Vec<_> = by_index - .iter() - .copied() - .filter(|handle| !handle.is_null()) - .map(|handle| (handle, crate::scene::ChangeKind::Modified)) - .collect(); - self.tabs[i].scene.bump_entities(&changes); - } - - /// Recreate the captured xdictionary subtrees in this document (fresh - /// handles, remapped references) WITHOUT an added host entity, returning - /// `entity_index → new xdictionary root`. Used by PASTEBLOCK, which folds - /// the clipboard into a new block definition: the caller stamps each new - /// root onto the matching entity's `xdictionary_handle` before defining the - /// block, so the block's nested insert keeps its XCLIP filter. - pub(super) fn recreate_clipboard_ext_roots( - &mut self, - i: usize, - ) -> std::collections::HashMap { - let mut out = std::collections::HashMap::new(); - if self.clipboard_deps.ext_objects.is_empty() { - return out; - } - let captures = self.clipboard_deps.ext_objects.clone(); - let doc = &mut self.tabs[i].scene.document; - for cap in &captures { - if let Some(new_root) = recreate_ext_subtree(doc, cap, None) { - out.insert(cap.entity_index, new_root); - } - } - out - } - - pub(super) fn restore_pre_cmd_tangent(&mut self) { - if let Some(was_on) = self.pre_cmd_tangent.take() { - if !was_on { - self.snapper.enabled.remove(&crate::snap::SnapType::Tangent); - } - } - if self.rect_suppressed_ortho { - self.rect_suppressed_ortho = false; - self.ortho_mode = true; - self.polar_mode = false; - } - } -} - -/// Clone one captured xdictionary subtree into `doc` with fresh handles, -/// remapping every internal reference (and the owning entity, when known), -/// returning the new root handle. `allocate_handle` advances the document's -/// handle counter — `next_handle()` only peeks, so reusing it would hand every -/// object the same handle and collapse the dictionary chain. -/// The entity a typed coordinate lands on while an object is asked for: -/// the nearest planar curve of the edited space within a small share of -/// that space's extent, the way a pick box takes the object under a click. -fn entity_at_typed_point( - document: &acadrust::CadDocument, - owner: Handle, - point: glam::DVec3, -) -> Option { - let mut extent = 0.0f64; - let mut nearest: Option<(f64, Handle)> = None; - for entity in document.entities() { - let common = entity.common(); - if common.owner_handle != owner { - continue; - } - let bounds = entity.as_entity().bounding_box(); - extent = extent - .max((bounds.max.x - bounds.min.x).abs()) - .max((bounds.max.y - bounds.min.y).abs()); - let distance = match crate::scene::viewport_dimension_pick::planar_pick_distance(entity, point) { - Some(distance) => distance, - // A text or an ellipse answers by its extent, the way a click - // inside it picks it. - None if matches!( - entity, - acadrust::EntityType::Text(_) - | acadrust::EntityType::MText(_) - | acadrust::EntityType::Ellipse(_) - ) => - { - let dx = (bounds.min.x - point.x).max(point.x - bounds.max.x).max(0.0); - let dy = (bounds.min.y - point.y).max(point.y - bounds.max.y).max(0.0); - dx.hypot(dy) - } - None => continue, - }; - if nearest.is_none_or(|(best, _)| distance < best) { - nearest = Some((distance, common.handle)); - } - } - let tolerance = (extent * 0.002).max(1.0e-9); - nearest - .filter(|(distance, _)| *distance <= tolerance) - .map(|(_, handle)| handle) -} - -fn recreate_ext_subtree( - doc: &mut acadrust::CadDocument, - cap: &crate::app::ClipExtObjects, - entity_handle: Option, -) -> Option { - use std::collections::HashMap; - let mut remap: HashMap = HashMap::new(); - if let Some(eh) = entity_handle { - remap.insert(cap.src_entity_handle, eh); - } - for (old, scale) in &cap.annotation_scales { - let target = crate::scene::annotative::ensure_scale_object(doc, scale); - remap.insert(*old, target); - } - for (old, _) in &cap.objects { - remap.insert(*old, doc.allocate_handle()); - } - for (old, obj) in &cap.objects { - let mut obj = obj.clone(); - let new_h = remap[old]; - remap_object(&mut obj, new_h, &remap); - doc.objects.insert(new_h, obj); - } - remap.get(&cap.root).copied() -} - -/// Replace references to a clipboard entity inside one recreated extension -/// dictionary graph after its final block-owned handle becomes known. -pub(crate) fn remap_ext_subtree_reference( - doc: &mut acadrust::CadDocument, - root: Handle, - source_entity: Handle, - target_entity: Handle, -) { - use acadrust::objects::ObjectType; - use rustc_hash::FxHashSet; - use std::collections::HashMap; - - let remap = HashMap::from([(source_entity, target_entity)]); - let mut seen = FxHashSet::default(); - let mut pending = vec![root]; - while let Some(handle) = pending.pop() { - if handle.is_null() || !seen.insert(handle) { - continue; - } - let children = match doc.objects.get(&handle) { - Some(ObjectType::Dictionary(dictionary)) => { - let mut children: Vec<_> = - dictionary.entries.iter().map(|(_, child)| *child).collect(); - if let Some(extension) = dictionary.xdictionary_handle { - children.push(extension); - } - children - } - Some(ObjectType::DictionaryWithDefault(dictionary)) => { - let mut children: Vec<_> = - dictionary.entries.iter().map(|(_, child)| *child).collect(); - children.push(dictionary.default_handle); - children - } - _ => Vec::new(), - }; - pending.extend(children); - if let Some(mut object) = doc.objects.remove(&handle) { - remap_object(&mut object, handle, &remap); - doc.objects.insert(handle, object); - } - } -} - -/// Rewrite a cloned extension-dictionary object onto fresh handles: set its own -/// handle to `new_handle` and remap its owner and any handle references it holds -/// through `remap` (a handle still in the source space stays unchanged, which is -/// correct for cross-references that point outside the captured subtree). -fn remap_object( - obj: &mut acadrust::objects::ObjectType, - new_handle: acadrust::Handle, - remap: &std::collections::HashMap, -) { - use acadrust::objects::ObjectType; - let map = |h: acadrust::Handle| remap.get(&h).copied().unwrap_or(h); - match obj { - ObjectType::Dictionary(d) => { - d.handle = new_handle; - d.owner = map(d.owner); - for (_, h) in d.entries.iter_mut() { - *h = map(*h); - } - if let Some(x) = d.xdictionary_handle.as_mut() { - *x = map(*x); - } - for r in d.reactors.iter_mut() { - *r = map(*r); - } - } - ObjectType::DictionaryWithDefault(d) => { - d.handle = new_handle; - d.owner = map(d.owner); - for (_, h) in d.entries.iter_mut() { - *h = map(*h); - } - d.default_handle = map(d.default_handle); - } - ObjectType::DictionaryVariable(v) => { - v.handle = new_handle; - v.owner_handle = map(v.owner_handle); - } - ObjectType::SpatialFilter(s) => { - s.handle = new_handle; - s.owner = map(s.owner); - } - ObjectType::XRecord(x) => { - x.handle = new_handle; - x.owner = map(x.owner); - for entry in &mut x.entries { - if let acadrust::objects::XRecordValue::Handle(handle) = &mut entry.value { - *handle = map(*handle); - } - } - } - ObjectType::Group(g) => { - g.handle = new_handle; - g.owner = map(g.owner); - for h in g.entities.iter_mut() { - *h = map(*h); - } - } - ObjectType::ObjectContextData(context) => { - context.handle = new_handle; - context.owner_handle = map(context.owner_handle); - for reactor in &mut context.reactors { - *reactor = map(*reactor); - } - if let Some(dictionary) = &mut context.xdictionary_handle { - *dictionary = map(*dictionary); - } - context.scale = map(context.scale); - match &mut context.kind { - acadrust::objects::ObjectContextKind::Dim(dimension) => { - dimension.block = map(dimension.block); - } - acadrust::objects::ObjectContextKind::HatchView(hatch) => { - hatch.view = map(hatch.view); - } - acadrust::objects::ObjectContextKind::MTextAttribute(attribute) => { - if let Some(embedded) = &mut attribute.context { - embedded.owner_handle = map(embedded.owner_handle); - for reactor in &mut embedded.reactors { - *reactor = map(*reactor); - } - if let Some(dictionary) = &mut embedded.xdictionary_handle { - *dictionary = map(*dictionary); - } - embedded.scale = map(embedded.scale); - } - } - acadrust::objects::ObjectContextKind::MLeader(mleader) => { - if let Some(handle) = &mut mleader.text_style_handle { - *handle = map(*handle); - } - if let Some(handle) = &mut mleader.block_content_handle { - *handle = map(*handle); - } - if let Some(handle) = &mut mleader.scale_handle { - *handle = map(*handle); - } - for root in &mut mleader.leader_roots { - for line in &mut root.lines { - if let Some(handle) = &mut line.line_type_handle { - *handle = map(*handle); - } - if let Some(handle) = &mut line.arrowhead_handle { - *handle = map(*handle); - } - } - } - } - _ => {} - } - } - // Other leaf object kinds don't appear in an entity xdictionary; if one - // does, it's inserted with the fresh handle below via the caller's key, - // but its internal owner is left as-is (best effort). - _ => {} - } -} - -// ── DIMSPACE helper ─────────────────────────────────────────────────────────── - -struct DimBreakResult { - changed: Vec, - message: String, -} - -fn wire_segments( - models: &[crate::scene::model::wire_model::WireModel], -) -> Vec<(glam::DVec3, glam::DVec3)> { - let mut output = Vec::new(); - for model in models { - for run in model.points.split(|point| !point[0].is_finite()) { - for pair in run.windows(2) { - let first = - glam::DVec3::new(pair[0][0] as f64, pair[0][1] as f64, pair[0][2] as f64); - let second = - glam::DVec3::new(pair[1][0] as f64, pair[1][1] as f64, pair[1][2] as f64); - if first.is_finite() - && second.is_finite() - && first.distance_squared(second) > 1.0e-18 - { - output.push((first, second)); - } - } - } - } - output -} - -fn segment_intersection_xy( - first: (glam::DVec3, glam::DVec3), - second: (glam::DVec3, glam::DVec3), -) -> Option<(glam::DVec3, glam::DVec3)> { - let gap = cadkernel::space::segment_break_gap_xy( - [first.0.to_array(), first.1.to_array()], - [second.0.to_array(), second.1.to_array()], - 0.25, - )?; - Some(( - glam::DVec3::from_array(gap[0]), - glam::DVec3::from_array(gap[1]), - )) -} - -fn break_object_handle(document: &acadrust::CadDocument, dimension: Handle) -> Option { - document.objects.iter().find_map(|(handle, object)| { - let acadrust::objects::ObjectType::DataObject(object) = object else { - return None; - }; - let acadrust::objects::DataObjectData::BreakData(data) = &object.data else { - return None; - }; - (data.dimension_reference == dimension).then_some(*handle) - }) -} - -fn remove_dimension_break_data(document: &mut acadrust::CadDocument, dimension: Handle) -> bool { - let handles: Vec<_> = document - .objects - .iter() - .filter_map(|(handle, object)| match object { - acadrust::objects::ObjectType::DataObject(object) => match &object.data { - acadrust::objects::DataObjectData::BreakData(data) - if data.dimension_reference == dimension => - { - Some(*handle) - } - _ => None, - }, - _ => None, - }) - .collect(); - if handles.is_empty() { - return false; - } - for handle in &handles { - document.objects.remove(handle); - } - if let Some(dictionary_handle) = document.extension_dictionary_handle(dimension) { - if let Some(acadrust::objects::ObjectType::Dictionary(dictionary)) = - document.objects.get_mut(&dictionary_handle) - { - dictionary.entries.retain(|(name, handle)| { - !name.eq_ignore_ascii_case("ACAD_BREAKDATA") && !handles.contains(handle) - }); - dictionary - .hard_owner_entries - .retain(|name| !name.eq_ignore_ascii_case("ACAD_BREAKDATA")); - } - } - true -} - -fn write_dimension_break_data( - document: &mut acadrust::CadDocument, - dimension: Handle, - reserved_reference: Handle, - references: Vec, -) -> bool { - use acadrust::objects::{BreakData, DataObject, DataObjectData, Dictionary, ObjectType}; - - if references.is_empty() { - return remove_dimension_break_data(document, dimension); - } - if let Some(handle) = break_object_handle(document, dimension) { - if let Some(ObjectType::DataObject(object)) = document.objects.get_mut(&handle) { - object.data = DataObjectData::BreakData(BreakData { - version: 0, - dimension_reference: dimension, - reserved_reference, - point_references: references, - }); - return true; - } - } - - let dictionary_handle = match document.extension_dictionary_handle(dimension) { - Some(handle) => handle, - None => { - let handle = document.allocate_handle(); - let mut dictionary = Dictionary::new(); - dictionary.handle = handle; - dictionary.owner = dimension; - dictionary.hard_owner = true; - document - .objects - .insert(handle, ObjectType::Dictionary(dictionary)); - if let Some(entity) = document.get_entity_mut(dimension) { - entity.common_mut().xdictionary_handle = Some(handle); - } - handle - } - }; - - let object_handle = document.allocate_handle(); - let mut object = DataObject::new(DataObjectData::BreakData(BreakData { - version: 0, - dimension_reference: dimension, - reserved_reference, - point_references: references, - })); - object.handle = object_handle; - object.owner = dictionary_handle; - document - .objects - .insert(object_handle, ObjectType::DataObject(object)); - if let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&dictionary_handle) { - dictionary - .entries - .retain(|(name, _)| !name.eq_ignore_ascii_case("ACAD_BREAKDATA")); - dictionary.add_entry("ACAD_BREAKDATA", object_handle); - dictionary.set_entry_hard_owner("ACAD_BREAKDATA", true); - } - true -} - -fn break_reference( - identifier: i32, - reference_type: i32, - first: glam::DVec3, - second: glam::DVec3, -) -> acadrust::objects::BreakPointReference { - acadrust::objects::BreakPointReference { - version: 0, - reserved: 0, - reference_type, - flags: 0, - identifier, - first_point: acadrust::types::Vector3::new(first.x, first.y, first.z), - second_point: acadrust::types::Vector3::new(second.x, second.y, second.z), - trailing_version: 0, - } -} - -fn apply_dimbreak( - scene: &mut crate::scene::Scene, - requested: &[Handle], - operation: crate::command::DimensionBreakOperation, -) -> DimBreakResult { - use crate::command::DimensionBreakOperation; - - let dimensions: Vec<_> = requested - .iter() - .copied() - .filter(|handle| { - matches!( - scene.document.get_entity(*handle), - Some(acadrust::EntityType::Dimension(_)) - ) && scene.locked_layer_name(*handle).is_none() - }) - .collect(); - if dimensions.is_empty() { - return DimBreakResult { - changed: Vec::new(), - message: crate::t!("DIMBREAK: no editable dimensions were selected.").into_owned(), - }; - } - - if matches!(operation, DimensionBreakOperation::Remove) { - let changed: Vec<_> = dimensions - .into_iter() - .filter(|handle| remove_dimension_break_data(&mut scene.document, *handle)) - .collect(); - return DimBreakResult { - message: crate::tf!( - "DIMBREAK: removed breaks from {} dimension(s).", - changed.len() - ) - .into_owned(), - changed, - }; - } - - let crossing_handles: Vec = match operation { - DimensionBreakOperation::Object(handle) => vec![handle], - DimensionBreakOperation::Auto => scene - .document - .entities() - .map(|entity| entity.common().handle) - .filter(|handle| { - !dimensions.contains(handle) && scene.entity_belongs_to_active_space(*handle) - }) - .collect(), - DimensionBreakOperation::Manual(_, _) | DimensionBreakOperation::Remove => Vec::new(), - }; - let crossing_segments: Vec<_> = crossing_handles - .iter() - .flat_map(|handle| wire_segments(&scene.wire_models_for(&[*handle]))) - .collect(); - - let mut pending = Vec::new(); - for dimension in &dimensions { - let existing: Vec<_> = break_object_handle(&scene.document, *dimension) - .and_then(|handle| scene.document.objects.get(&handle)) - .and_then(|object| match object { - acadrust::objects::ObjectType::DataObject(object) => match &object.data { - acadrust::objects::DataObjectData::BreakData(data) => { - Some(data.point_references.clone()) - } - _ => None, - }, - _ => None, - }) - .unwrap_or_default(); - let mut references = if matches!(operation, DimensionBreakOperation::Manual(_, _)) { - existing - } else { - existing - .into_iter() - .filter(|reference| reference.reference_type == 2) - .collect() - }; - match operation { - DimensionBreakOperation::Manual(first, second) => { - references.push(break_reference(references.len() as i32, 2, first, second)); - } - DimensionBreakOperation::Auto | DimensionBreakOperation::Object(_) => { - let dimension_segments = wire_segments(&scene.wire_models_for(&[*dimension])); - let mut intersections = Vec::new(); - for dim_segment in &dimension_segments { - for crossing_segment in &crossing_segments { - if let Some(points) = - segment_intersection_xy(*dim_segment, *crossing_segment) - { - let center = (points.0 + points.1) * 0.5; - if intersections.iter().all( - |(first, second): &(glam::DVec3, glam::DVec3)| { - center.distance_squared((*first + *second) * 0.5) > 1.0e-8 - }, - ) { - intersections.push(points); - } - } - } - } - let start = references.len() as i32; - references.extend( - intersections - .into_iter() - .enumerate() - .map(|(index, points)| { - break_reference(start + index as i32, 1, points.0, points.1) - }), - ); - } - DimensionBreakOperation::Remove => {} - } - pending.push((*dimension, references)); - } - - let reserved = match operation { - DimensionBreakOperation::Object(handle) => handle, - _ => Handle::NULL, - }; - let changed: Vec<_> = pending - .into_iter() - .filter_map(|(dimension, references)| { - write_dimension_break_data(&mut scene.document, dimension, reserved, references) - .then_some(dimension) - }) - .collect(); - DimBreakResult { - message: if changed.is_empty() { - crate::t!("DIMBREAK: no intersections were found.").into_owned() - } else { - crate::tf!("DIMBREAK: updated {} dimension(s).", changed.len()).into_owned() - }, - changed, - } -} - -fn apply_dimspace( - scene: &mut crate::scene::Scene, - base_h: Handle, - others: &[Handle], - requested_spacing: Option, -) -> bool { - use acadrust::entities::Dimension; - let (axis, normal, definition, auto_spacing) = match scene.document.get_entity(base_h) { - Some(acadrust::EntityType::Dimension(dimension @ Dimension::Linear(d))) => { - let spacing = dimension_auto_spacing( - &scene.document, - dimension, - scene.creation_annotation_multiplier(), - ); - ( - [d.rotation.cos(), d.rotation.sin(), 0.0], - [d.base.normal.x, d.base.normal.y, d.base.normal.z], - [ - d.definition_point.x, - d.definition_point.y, - d.definition_point.z, - ], - spacing, - ) - } - Some(acadrust::EntityType::Dimension(dimension @ Dimension::Aligned(d))) => { - let spacing = dimension_auto_spacing( - &scene.document, - dimension, - scene.creation_annotation_multiplier(), - ); - ( - [ - d.second_point.x - d.first_point.x, - d.second_point.y - d.first_point.y, - d.second_point.z - d.first_point.z, - ], - [d.base.normal.x, d.base.normal.y, d.base.normal.z], - [ - d.definition_point.x, - d.definition_point.y, - d.definition_point.z, - ], - spacing, - ) - } - _ => return false, - }; - let Some(frame) = cadkernel::space::dimension_spacing_frame(axis, normal, definition) else { - return false; - }; - - let effective_spacing = requested_spacing.unwrap_or(auto_spacing); - let mut changes = Vec::new(); - for (idx, &h) in others.iter().enumerate() { - let target = frame.coordinate + effective_spacing * (idx + 1) as f64; - let mut changed = false; - if let Some(acadrust::EntityType::Dimension(dim)) = scene.document.get_entity_mut(h) { - let slide = |p: &mut acadrust::types::Vector3| { - let point = - cadkernel::space::move_to_dimension_spacing([p.x, p.y, p.z], frame, target); - *p = acadrust::types::Vector3::new(point[0], point[1], point[2]); - }; - match dim { - Dimension::Linear(d) => { - slide(&mut d.definition_point); - d.base.definition_point = d.definition_point; - changed = true; - } - Dimension::Aligned(d) => { - slide(&mut d.definition_point); - d.base.definition_point = d.definition_point; - changed = true; - } - _ => {} - } - } - if changed { - // The dimension line moved, so its baked *D block is stale — drop it - // so the next save re-bakes it. (#181) - scene.invalidate_dim_block_recorded(h); - changes.push((h, crate::scene::ChangeKind::Modified)); - } - } - if !changes.is_empty() { - scene.bump_entities(&changes); - } - !changes.is_empty() -} - -fn dimension_auto_spacing( - document: &acadrust::CadDocument, - dimension: &acadrust::entities::Dimension, - annotation_multiplier: f64, -) -> f64 { - let style = document - .dim_styles - .iter() - .find(|style| { - style - .name - .eq_ignore_ascii_case(&dimension.base().style_name) - }) - .map(|style| { - crate::entities::dimension::resolved_dimension_style(style, dimension, document) - }); - style - .map(|style| { - let scale = if style.dimscale > 1.0e-9 { - style.dimscale - } else { - annotation_multiplier.max(1.0e-9) - }; - (style.dimdli.abs() * scale).max(1.0e-6) - }) - .unwrap_or(3.75) -} - -fn apply_mleader_align( - scene: &mut crate::scene::Scene, - handles: &[acadrust::Handle], - from: glam::DVec3, - to: glam::DVec3, -) -> bool { - use cadkernel::geom2d::{closest_point, Curve, Vec2, XLine}; - - let Some(direction) = Vec2::new(to.x - from.x, to.y - from.y).normalize() else { - return false; - }; - let line = Curve::XLine(XLine { - base: [from.x, from.y], - direction: direction.into(), - }); - let mut changed = Vec::new(); - for &handle in handles { - if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity_mut(handle) { - let old = ml.context.content_base_point; - let projected = closest_point(&line, [old.x, old.y]).point; - let new_x = projected[0]; - let new_y = projected[1]; - let shift_x = new_x - old.x; - let shift_y = new_y - old.y; - if shift_x.abs() <= 1.0e-12 && shift_y.abs() <= 1.0e-12 { - continue; - } - ml.context.content_base_point.x = new_x; - ml.context.content_base_point.y = new_y; - ml.context.text_location.x += shift_x; - ml.context.text_location.y += shift_y; - ml.context.block_content_location.x += shift_x; - ml.context.block_content_location.y += shift_y; - for root in &mut ml.context.leader_roots { - root.connection_point.x += shift_x; - root.connection_point.y += shift_y; - } - changed.push((handle, crate::scene::ChangeKind::Modified)); - } - } - if !changed.is_empty() { - scene.bump_entities(&changed); - } - !changed.is_empty() -} - -fn compatible_mleader_collect_handles( - scene: &crate::scene::Scene, - handles: &[acadrust::Handle], -) -> Vec { - let Some((base_block, base_style)) = handles.iter().find_map(|handle| { - if scene.is_layer_locked(*handle) { - return None; - } - let acadrust::EntityType::MultiLeader(leader) = scene.document.get_entity(*handle)? else { - return None; - }; - (leader.content_type == acadrust::entities::LeaderContentType::Block) - .then_some((leader.block_content_handle, leader.style_handle)) - }) else { - return Vec::new(); - }; - let mut compatible = Vec::new(); - for handle in handles.iter().copied() { - if compatible.contains(&handle) { - continue; - } - if !scene.is_layer_locked(handle) - && matches!( - scene.document.get_entity(handle), - Some(acadrust::EntityType::MultiLeader(leader)) - if leader.content_type == acadrust::entities::LeaderContentType::Block - && leader.block_content_handle == base_block - && leader.style_handle == base_style - ) - { - compatible.push(handle); - } - } - compatible -} - -fn apply_mleader_collect( - scene: &mut crate::scene::Scene, - handles: &[acadrust::Handle], - point: glam::DVec3, -) -> bool { - if handles.len() < 2 { - return false; - } - let px = point.x; - let py = point.y; - let Some(acadrust::EntityType::MultiLeader(base)) = scene.document.get_entity(handles[0]) - else { - return false; - }; - let base_block = base.block_content_handle; - let base_style = base.style_handle; - - let mut extra_roots: Vec = Vec::new(); - let mut merged_handles = Vec::new(); - for &h in &handles[1..] { - if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity(h) { - if ml.content_type == acadrust::entities::LeaderContentType::Block - && ml.block_content_handle == base_block - && ml.style_handle == base_style - { - let shift_x = px - ml.context.content_base_point.x; - let shift_y = py - ml.context.content_base_point.y; - extra_roots.extend(ml.context.leader_roots.iter().cloned().map(|mut root| { - root.connection_point.x += shift_x; - root.connection_point.y += shift_y; - root - })); - merged_handles.push(h); - } - } - } - if merged_handles.is_empty() { - return false; - } - - if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity_mut(handles[0]) { - let shift_x = px - ml.context.content_base_point.x; - let shift_y = py - ml.context.content_base_point.y; - for root in &mut ml.context.leader_roots { - root.connection_point.x += shift_x; - root.connection_point.y += shift_y; - } - ml.context.content_base_point.x = px; - ml.context.content_base_point.y = py; - ml.context.block_content_location.x = px; - ml.context.block_content_location.y = py; - ml.context.text_location.x = px; - ml.context.text_location.y = py; - for root in extra_roots { - ml.context.leader_roots.push(root); - } - } - - scene.erase_entities(&merged_handles); - scene.bump_entities(&[(handles[0], crate::scene::ChangeKind::Modified)]); - true -} - -/// MATCHPROP special properties (#281): copy every STYLE-affecting field from -/// `src` to `dst` when the destination supports it — never content (text -/// strings, block names) or placement (positions, rotations of the object -/// itself). Text formatting crosses TEXT ↔ MTEXT; the dimension style crosses -/// Dimension / Leader / Tolerance. -fn match_special_props(src: &acadrust::EntityType, dst: &mut acadrust::EntityType) { - use acadrust::EntityType as E; - - // Text-ish source formatting. - let text_fmt = match src { - E::Text(t) => Some(( - t.style.clone(), - t.height, - Some(t.width_factor), - Some(t.oblique_angle), - )), - E::MText(m) => Some((m.style.clone(), m.height, None, None)), - E::AttributeDefinition(a) => Some(( - a.text_style.clone(), - a.height, - Some(a.width_factor), - Some(a.oblique_angle), - )), - E::AttributeEntity(a) => Some(( - a.text_style.clone(), - a.height, - Some(a.width_factor), - Some(a.oblique_angle), - )), - _ => None, - }; - if let Some((style, height, wf, ob)) = text_fmt { - match dst { - E::Text(t) => { - t.style = style.clone(); - t.height = height; - if let Some(value) = wf { - t.width_factor = value; - } - if let Some(value) = ob { - t.oblique_angle = value; - } - } - E::MText(m) => { - m.style = style.clone(); - m.height = height; - } - E::AttributeDefinition(a) => { - a.text_style = style.clone(); - a.height = height; - if let Some(value) = wf { - a.width_factor = value; - } - if let Some(value) = ob { - a.oblique_angle = value; - } - } - E::AttributeEntity(a) => { - a.text_style = style; - a.height = height; - if let Some(value) = wf { - a.width_factor = value; - } - if let Some(value) = ob { - a.oblique_angle = value; - } - } - _ => {} - } - } - // MText-only extras (line spacing, background fill). - if let (E::MText(sm), E::MText(dm)) = (src, dst as &mut E) { - dm.drawing_direction = sm.drawing_direction; - dm.line_spacing_factor = sm.line_spacing_factor; - dm.line_spacing_style = sm.line_spacing_style; - dm.background_fill_flags = sm.background_fill_flags; - dm.background_scale = sm.background_scale; - dm.background_color = sm.background_color; - dm.background_transparency = sm.background_transparency; - } - - // Dimension style name crosses the three dim-styled families. - let dim_style = match src { - E::Dimension(d) => Some(d.base().style_name.clone()), - E::Leader(l) => Some(l.dimension_style.clone()), - E::Tolerance(t) => Some(t.dimension_style_name.clone()), - _ => None, - }; - if let Some(ds) = dim_style { - match dst { - E::Dimension(d) => d.base_mut().style_name = ds, - E::Leader(l) => l.dimension_style = ds, - E::Tolerance(t) => t.dimension_style_name = ds, - _ => {} - } - } - - // Hatch pattern / gradient — everything but the boundary. - if let (E::Hatch(sh), E::Hatch(dh)) = (src, dst as &mut E) { - dh.pattern = sh.pattern.clone(); - dh.pattern_type = sh.pattern_type; - dh.pattern_angle = sh.pattern_angle; - dh.pattern_scale = sh.pattern_scale; - dh.is_solid = sh.is_solid; - dh.is_double = sh.is_double; - dh.style = sh.style; - dh.gradient_color = sh.gradient_color.clone(); - } - - // Polyline display style crosses lightweight and legacy 2D polylines. - // Per-vertex/tapered widths are resampled over the destination vertices; - // they must not be flattened into the source's constant-width field. - if let Some(style) = PolylineMatchStyle::from_entity(src) { - style.apply_to(dst); - } - - if let (E::Leader(sl), E::Leader(dl)) = (src, dst as &mut E) { - dl.arrow_enabled = sl.arrow_enabled; - dl.path_type = sl.path_type; - dl.hookline_direction = sl.hookline_direction; - dl.hookline_enabled = sl.hookline_enabled; - dl.override_color = sl.override_color; - dl.dimension_gap = sl.dimension_gap; - dl.arrowhead_type = sl.arrowhead_type; - dl.arrow_size = sl.arrow_size; - dl.byblock_color = sl.byblock_color; - } - if let (E::Tolerance(st), E::Tolerance(dt)) = (src, dst as &mut E) { - dt.dimension_style_handle = st.dimension_style_handle; - dt.text_height = st.text_height; - dt.dimension_gap = st.dimension_gap; - } - - // Paper-space geometry and view position stay; viewport display/plot - // styling and effective scale follow the source. - if let (E::Viewport(sv), E::Viewport(dv)) = (src, dst as &mut E) { - let scale = crate::scene::vp_effective_scale(sv.custom_scale, sv.view_height, sv.height); - dv.custom_scale = scale; - if scale.abs() > 1e-9 { - dv.view_height = dv.height / scale; - } - dv.status.locked = sv.status.locked; - dv.status.hide_plot = sv.status.hide_plot; - dv.render_mode = sv.render_mode; - dv.style_sheet = sv.style_sheet.clone(); - dv.shade_plot_mode = sv.shade_plot_mode; - dv.background_handle = sv.background_handle; - dv.shade_plot_handle = sv.shade_plot_handle; - dv.visual_style_handle = sv.visual_style_handle; - dv.default_lighting = sv.default_lighting; - dv.default_lighting_type = sv.default_lighting_type; - dv.brightness = sv.brightness; - dv.contrast = sv.contrast; - dv.ambient_color = sv.ambient_color; - } - - if let (E::Table(st), E::Table(dt)) = (src, dst as &mut E) { - dt.table_style_handle = st.table_style_handle; - dt.base_style = st.base_style.clone(); - dt.override_flag = st.override_flag; - dt.override_border_color = st.override_border_color; - dt.override_border_line_weight = st.override_border_line_weight; - dt.override_border_visibility = st.override_border_visibility; - dt.legacy_style_override = st.legacy_style_override.clone(); - dt.legacy_border_colors = st.legacy_border_colors.clone(); - dt.legacy_border_line_weights = st.legacy_border_line_weights.clone(); - dt.legacy_border_visibility = st.legacy_border_visibility.clone(); - } - - if let (E::MLine(sm), E::MLine(dm)) = (src, dst as &mut E) { - dm.style_handle = sm.style_handle; - dm.style_name = sm.style_name.clone(); - dm.style_element_count = sm.style_element_count; - dm.justification = sm.justification; - dm.scale_factor = sm.scale_factor; - // Stored segment offsets bake the old style into each vertex. Empty - // data deliberately selects the renderer's style-derived fallback. - for vertex in &mut dm.vertices { - vertex.segments.clear(); - } - } - - // MultiLeader style + every style-affecting override; content and - // geometry (leader points, text, block handle) stay. - if let (E::MultiLeader(sm), E::MultiLeader(dm)) = (src, dst as &mut E) { - dm.style_handle = sm.style_handle; - dm.path_type = sm.path_type; - dm.line_color = sm.line_color; - dm.line_type_handle = sm.line_type_handle; - dm.line_weight = sm.line_weight; - dm.enable_landing = sm.enable_landing; - dm.enable_dogleg = sm.enable_dogleg; - dm.dogleg_length = sm.dogleg_length; - dm.arrowhead_handle = sm.arrowhead_handle; - dm.arrowhead_size = sm.arrowhead_size; - dm.text_style_handle = sm.text_style_handle; - dm.text_color = sm.text_color; - dm.text_frame = sm.text_frame; - dm.text_height = sm.text_height; - dm.text_left_attachment = sm.text_left_attachment; - dm.text_right_attachment = sm.text_right_attachment; - dm.text_top_attachment = sm.text_top_attachment; - dm.text_bottom_attachment = sm.text_bottom_attachment; - dm.text_attachment_direction = sm.text_attachment_direction; - dm.text_attachment_point = sm.text_attachment_point; - dm.text_alignment = sm.text_alignment; - dm.text_angle_type = sm.text_angle_type; - dm.text_direction_negative = sm.text_direction_negative; - dm.text_align_in_ipe = sm.text_align_in_ipe; - dm.block_content_color = sm.block_content_color; - dm.block_connection_type = sm.block_connection_type; - dm.block_scale = sm.block_scale; - dm.scale_factor = sm.scale_factor; - dm.property_override_flags = sm.property_override_flags; - dm.enable_annotation_scale = sm.enable_annotation_scale; - dm.extend_leader_to_text = sm.extend_leader_to_text; - dm.arrowhead_overrides = sm.arrowhead_overrides.clone(); - - let sc = &sm.context; - let dc = &mut dm.context; - dc.scale_factor = sc.scale_factor; - dc.text_height = sc.text_height; - dc.text_width = sc.text_width; - dc.text_boundary_height = sc.text_boundary_height; - dc.line_spacing_factor = sc.line_spacing_factor; - dc.line_spacing_style = sc.line_spacing_style; - dc.text_color = sc.text_color; - dc.text_attachment_point = sc.text_attachment_point; - dc.text_flow_direction = sc.text_flow_direction; - dc.text_alignment = sc.text_alignment; - dc.text_left_attachment = sc.text_left_attachment; - dc.text_right_attachment = sc.text_right_attachment; - dc.text_top_attachment = sc.text_top_attachment; - dc.text_bottom_attachment = sc.text_bottom_attachment; - dc.text_height_automatic = sc.text_height_automatic; - dc.word_break = sc.word_break; - dc.text_style_handle = sc.text_style_handle; - dc.block_content_scale = sc.block_content_scale; - dc.block_content_color = sc.block_content_color; - dc.block_connection_type = sc.block_connection_type; - dc.column_type = sc.column_type; - dc.column_width = sc.column_width; - dc.column_gutter = sc.column_gutter; - dc.column_flow_reversed = sc.column_flow_reversed; - dc.column_sizes = sc.column_sizes.clone(); - dc.background_fill_enabled = sc.background_fill_enabled; - dc.background_mask_fill_on = sc.background_mask_fill_on; - dc.background_fill_color = sc.background_fill_color; - dc.background_scale_factor = sc.background_scale_factor; - dc.background_transparency = sc.background_transparency; - dc.arrowhead_size = sc.arrowhead_size; - dc.landing_gap = sc.landing_gap; - dc.scale_handle = sc.scale_handle; - } - - // External-reference identity, placement and clip geometry stay. Only - // display controls/appearance are matched. - if let (E::RasterImage(si), E::RasterImage(di)) = (src, dst as &mut E) { - di.flags = si.flags; - di.clipping_enabled = si.clipping_enabled; - di.brightness = si.brightness; - di.contrast = si.contrast; - di.fade = si.fade; - di.clip_boundary.clip_mode = si.clip_boundary.clip_mode; - } - if let (E::Wipeout(sw), E::Wipeout(dw)) = (src, dst as &mut E) { - dw.flags = sw.flags; - dw.clipping_enabled = sw.clipping_enabled; - dw.brightness = sw.brightness; - dw.contrast = sw.contrast; - dw.fade = sw.fade; - dw.clip_mode = sw.clip_mode; - } - if let (E::Underlay(su), E::Underlay(du)) = (src, dst as &mut E) { - du.flags = su.flags; - du.contrast = su.contrast; - du.fade = su.fade; - du.clip_inverted = su.clip_inverted; - } -} - -struct PolylineMatchStyle { - plinegen: bool, - widths: Vec<(f64, f64)>, -} - -impl PolylineMatchStyle { - fn from_entity(entity: &acadrust::EntityType) -> Option { - use acadrust::EntityType as E; - - match entity { - E::LwPolyline(poly) => { - let widths = if poly.vertices.is_empty() { - vec![(poly.constant_width, poly.constant_width)] - } else { - poly.vertices - .iter() - .map(|v| { - ( - width_or_default(v.start_width, poly.constant_width), - width_or_default(v.end_width, poly.constant_width), - ) - }) - .collect() - }; - Some(Self { - plinegen: poly.plinegen, - widths, - }) - } - E::Polyline2D(poly) => { - let widths = if poly.vertices.is_empty() { - vec![(poly.start_width, poly.end_width)] - } else { - poly.vertices - .iter() - .map(|v| { - ( - width_or_default(v.start_width, poly.start_width), - width_or_default(v.end_width, poly.end_width), - ) - }) - .collect() - }; - Some(Self { - plinegen: poly.flags.bits() - & acadrust::entities::PolylineFlags::LINETYPE_CONTINUOUS.bits() - != 0, - widths, - }) - } - _ => None, - } - } - - fn apply_to(&self, entity: &mut acadrust::EntityType) { - use acadrust::EntityType as E; - - match entity { - E::LwPolyline(poly) => { - poly.plinegen = self.plinegen; - let sampled = resample_widths(&self.widths, poly.vertices.len()); - let first = sampled.first().copied().unwrap_or((0.0, 0.0)); - let can_use_constant = widths_are_same(&sampled) && nearly_equal(first.0, first.1); - poly.constant_width = if can_use_constant { first.0 } else { 0.0 }; - for (vertex, (start, end)) in poly.vertices.iter_mut().zip(sampled) { - if can_use_constant { - vertex.start_width = 0.0; - vertex.end_width = 0.0; - } else { - vertex.start_width = start; - vertex.end_width = end; - } - } - } - E::Polyline2D(poly) => { - let mut bits = poly.flags.bits(); - let flag = acadrust::entities::PolylineFlags::LINETYPE_CONTINUOUS.bits(); - if self.plinegen { - bits |= flag; - } else { - bits &= !flag; - } - poly.flags = acadrust::entities::PolylineFlags::from_bits(bits); - let sampled = resample_widths(&self.widths, poly.vertices.len()); - let first = sampled.first().copied().unwrap_or((0.0, 0.0)); - let can_use_defaults = widths_are_same(&sampled); - if can_use_defaults { - poly.start_width = first.0; - poly.end_width = first.1; - } else { - poly.start_width = 0.0; - poly.end_width = 0.0; - } - for (vertex, (start, end)) in poly.vertices.iter_mut().zip(sampled) { - if can_use_defaults { - vertex.start_width = 0.0; - vertex.end_width = 0.0; - } else { - vertex.start_width = start; - vertex.end_width = end; - } - } - } - _ => {} - } - } -} - -fn width_or_default(value: f64, default: f64) -> f64 { - if value.abs() <= 1e-12 { - default - } else { - value - } -} - -fn nearly_equal(a: f64, b: f64) -> bool { - (a - b).abs() <= 1e-9 -} - -fn widths_are_same(widths: &[(f64, f64)]) -> bool { - let Some(first) = widths.first() else { - return true; - }; - widths - .iter() - .all(|value| nearly_equal(value.0, first.0) && nearly_equal(value.1, first.1)) -} - -fn resample_widths(source: &[(f64, f64)], count: usize) -> Vec<(f64, f64)> { - if count == 0 || source.is_empty() { - return Vec::new(); - } - if count == 1 || source.len() == 1 { - return vec![source[0]; count]; - } - (0..count) - .map(|index| { - let source_index = index.saturating_mul(source.len() - 1) / count.saturating_sub(1); - source[source_index] - }) - .collect() -} - -#[cfg(test)] -mod command_replacement_tests { - use super::*; - use acadrust::entities::{Circle, Line}; - use acadrust::types::Vector3; - - #[test] - fn one_to_one_edits_keep_identity_but_type_changes_do_not() { - let mut app = OpenCADStudio::new_for_test(); - let _ = app.automation_op(r#"{"op":"new"}"#); - let tab = app.active_tab; - let handle = app.tabs[tab] - .scene - .add_entity(acadrust::EntityType::Line(Line::from_points( - Vector3::ZERO, - Vector3::new(1.0, 0.0, 0.0), - ))); - let owner = app.tabs[tab] - .scene - .document - .get_entity(handle) - .unwrap() - .common() - .owner_handle; - - let kept = app.replace_command_entity( - tab, - handle, - vec![acadrust::EntityType::Line(Line::from_points( - Vector3::ZERO, - Vector3::new(3.0, 0.0, 0.0), - ))], - ); - assert_eq!(kept, vec![handle]); - let edited = app.tabs[tab].scene.document.get_entity(handle).unwrap(); - assert_eq!(edited.common().owner_handle, owner); - assert!(matches!(edited, acadrust::EntityType::Line(line) if line.end.x == 3.0)); - - let allocated = app.replace_command_entity( - tab, - handle, - vec![acadrust::EntityType::Circle(Circle::from_coords( - 0.0, 0.0, 0.0, 2.0, - ))], - ); - assert_eq!(allocated.len(), 1); - assert_ne!(allocated[0], handle); - assert!(app.tabs[tab].scene.document.get_entity(handle).is_none()); - } - - #[test] - fn live_geometry_updates_keep_document_identity_and_display_properties() { - let mut app = OpenCADStudio::new_for_test(); - let _ = app.automation_op(r#"{"op":"new"}"#); - let tab = app.active_tab; - let handle = app.tabs[tab] - .scene - .add_entity(acadrust::EntityType::Line(Line::from_points( - Vector3::ZERO, - Vector3::new(1.0, 0.0, 0.0), - ))); - let expected = { - let entity = app.tabs[tab].scene.document.get_entity_mut(handle).unwrap(); - entity.common_mut().layer = "LIVE".to_string(); - entity.common_mut().invisible = true; - entity.common_mut().linetype_scale = 2.5; - entity.common().clone() - }; - - let _ = app.apply_cmd_result(CmdResult::UpdateLiveEntity { - handle, - entity: acadrust::EntityType::Line(Line::from_points( - Vector3::ZERO, - Vector3::new(4.0, 0.0, 0.0), - )), - finish: false, - }); - - let updated = app.tabs[tab].scene.document.get_entity(handle).unwrap(); - assert_eq!(updated.common().handle, expected.handle); - assert_eq!(updated.common().owner_handle, expected.owner_handle); - assert_eq!(updated.common().layer, expected.layer); - assert_eq!(updated.common().invisible, expected.invisible); - assert_eq!(updated.common().linetype_scale, expected.linetype_scale); - assert!(matches!(updated, acadrust::EntityType::Line(line) if line.end.x == 4.0)); - } -} - -#[cfg(test)] -mod parametric_constraint_undo_tests { - use super::*; - use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef, ParametricScope}; - - fn add_line(app: &mut OpenCADStudio, x1: f64, y1: f64, x2: f64, y2: f64) -> Handle { - app.tabs[app.active_tab] - .scene - .add_entity(acadrust::EntityType::Line( - acadrust::entities::Line::from_points( - acadrust::types::Vector3::new(x1, y1, 0.0), - acadrust::types::Vector3::new(x2, y2, 0.0), - ), - )) - } - - fn line_angle_deg(app: &OpenCADStudio, handle: Handle) -> f64 { - match app.tabs[app.active_tab].scene.document.get_entity(handle) { - Some(acadrust::EntityType::Line(l)) => (l.end.y - l.start.y) - .atan2(l.end.x - l.start.x) - .to_degrees(), - other => panic!("expected a Line, got {other:?}"), - } - } - - /// Constraint state and solved geometry share one undo/redo transaction. - #[test] - fn adding_a_constraint_is_undoable_and_redoable_atomically() { - let mut app = OpenCADStudio::new_for_test(); - let _ = app.automation_op(r#"{"op":"new"}"#); - let line = add_line(&mut app, 0.0, 0.0, 10.0, 3.0); - let original_angle = line_angle_deg(&app, line); - assert!((original_angle - 16.699244).abs() < 1e-3); - - let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { - kind: ConstraintKind::Horizontal, - refs: vec![ParametricRef::whole(line)], - driving_param: None, - label: "Horizontal constraint", - }); - assert!( - line_angle_deg(&app, line).abs() < 1e-6, - "constraint should have leveled the line" - ); - assert_eq!( - app.tabs[app.active_tab] - .scene - .parametric_constraint_set(ParametricScope::ModelSpace) - .unwrap() - .constraints - .len(), - 1 - ); - - app.undo_steps(1); - assert_eq!( - app.tabs[app.active_tab] - .scene - .parametric_constraint_set(ParametricScope::ModelSpace) - .map(|s| s.constraints.len()) - .unwrap_or(0), - 0, - "undo should remove the constraint record" - ); - assert!( - (line_angle_deg(&app, line) - original_angle).abs() < 1e-3, - "undo should restore the pre-constraint angle" - ); - - app.redo_steps(1); - assert!( - line_angle_deg(&app, line).abs() < 1e-6, - "redo should re-level the line" - ); - assert_eq!( - app.tabs[app.active_tab] - .scene - .parametric_constraint_set(ParametricScope::ModelSpace) - .unwrap() - .constraints - .len(), - 1, - "redo should restore the constraint record" - ); - } - - #[test] - fn perpendicular_initial_solve_rotates_parallel_second_line_in_kernel() { - let mut app = OpenCADStudio::new_for_test(); - let _ = app.automation_op(r#"{"op":"new"}"#); - let first = add_line(&mut app, 0.0, 0.0, 10.0, 0.0); - let second = add_line(&mut app, 20.0, 0.0, 30.0, 0.0); - - let _ = app.apply_cmd_result(CmdResult::AddPerpendicularConstraint { - first: ParametricRef::whole(first), - second: ParametricRef::whole(second), - first_fixed: ParametricRef::whole(first), - second_start: ParametricRef::point(second, 0), - label: "Perpendicular constraint", - }); - - let line = |handle| match app.tabs[app.active_tab].scene.document.get_entity(handle) { - Some(acadrust::EntityType::Line(line)) => line.clone(), - other => panic!("expected a Line, got {other:?}"), - }; - let fixed = line(first); - let moving = line(second); - assert_eq!(fixed.start, acadrust::types::Vector3::new(0.0, 0.0, 0.0)); - assert_eq!(fixed.end, acadrust::types::Vector3::new(10.0, 0.0, 0.0)); - assert!((moving.start - acadrust::types::Vector3::new(20.0, 0.0, 0.0)).length() < 1.0e-9); - assert!((moving.length() - 10.0).abs() < 1.0e-7); - assert!( - (fixed.end - fixed.start) - .dot(&(moving.end - moving.start)) - .abs() - < 1.0e-7, - "fixed={fixed:?}, moving={moving:?}" - ); - } - - #[test] - fn horizontal_initial_solve_uses_the_captured_axis_in_kernel() { - let mut app = OpenCADStudio::new_for_test(); - let _ = app.automation_op(r#"{"op":"new"}"#); - let handle = add_line(&mut app, 0.0, 0.0, 5.0, 2.0); - let (original_start, original_end, original_length) = match app.tabs[app.active_tab] - .scene - .document - .get_entity(handle) - { - Some(acadrust::EntityType::Line(line)) => (line.start, line.end, line.length()), - other => panic!("expected a Line, got {other:?}"), - }; - let direction = acadrust::types::Vector3::new(3.0, 4.0, 0.0).normalize(); - - let _ = app.apply_cmd_result(CmdResult::AddHorizontalConstraint { - kind: crate::scene::parametric_constraints::ConstraintKind::Horizontal, - selection: crate::command::HorizontalConstraintSelection::Reference( - ParametricRef::whole(handle), - ), - direction, - label: "Horizontal constraint", - }); - - let line = match app.tabs[app.active_tab].scene.document.get_entity(handle) { - Some(acadrust::EntityType::Line(line)) => line, - other => panic!("expected a Line, got {other:?}"), - }; - let solved = (line.end - line.start).normalize(); - assert!(solved.cross(&direction).length() < 1.0e-7); - assert!((line.length() - original_length).abs() < 1.0e-7); - let constraint = &app.tabs[app.active_tab] - .scene - .parametric_constraint_set(ParametricScope::ModelSpace) - .unwrap() - .constraints[0]; - assert_eq!(constraint.axis_direction, Some(direction)); - - app.undo_steps(1); - let line = match app.tabs[app.active_tab].scene.document.get_entity(handle) { - Some(acadrust::EntityType::Line(line)) => line, - other => panic!("expected a Line after undo, got {other:?}"), - }; - assert_eq!((line.start, line.end), (original_start, original_end)); - assert_eq!( - app.tabs[app.active_tab] - .scene - .parametric_constraint_set(ParametricScope::ModelSpace) - .map(|set| set.constraints.len()) - .unwrap_or(0), - 0 - ); - - app.redo_steps(1); - let line = match app.tabs[app.active_tab].scene.document.get_entity(handle) { - Some(acadrust::EntityType::Line(line)) => line, - other => panic!("expected a Line after redo, got {other:?}"), - }; - assert!((line.end - line.start).normalize().cross(&direction).length() < 1.0e-7); - } - - #[test] - fn horizontal_two_point_solve_keeps_the_first_point_fixed() { - let mut app = OpenCADStudio::new_for_test(); - let _ = app.automation_op(r#"{"op":"new"}"#); - let first = add_line(&mut app, 0.0, 0.0, 0.0, 5.0); - let second = add_line(&mut app, 8.0, 3.0, 8.0, 7.0); - let pick = |handle, point| crate::command::CoincidentPick { - handle: Some(handle), - point, - whole_curve: false, - }; - - let _ = app.apply_cmd_result(CmdResult::AddHorizontalConstraint { - kind: crate::scene::parametric_constraints::ConstraintKind::Horizontal, - selection: crate::command::HorizontalConstraintSelection::Points( - pick(first, glam::DVec3::ZERO), - pick(second, glam::DVec3::new(8.0, 3.0, 0.0)), - ), - direction: acadrust::types::Vector3::UNIT_X, - label: "Horizontal constraint", - }); - - let line = |handle| match app.tabs[app.active_tab].scene.document.get_entity(handle) { - Some(acadrust::EntityType::Line(line)) => line, - other => panic!("expected a Line, got {other:?}"), - }; - assert_eq!(line(first).start, acadrust::types::Vector3::ZERO); - assert!(line(second).start.y.abs() < 1.0e-8); - } - - #[test] - fn horizontal_minor_axis_rotates_an_ellipse_around_its_center() { - let mut app = OpenCADStudio::new_for_test(); - let _ = app.automation_op(r#"{"op":"new"}"#); - let mut ellipse = acadrust::entities::Ellipse::default(); - ellipse.center = acadrust::types::Vector3::new(3.0, 7.0, 0.0); - ellipse.major_axis = acadrust::types::Vector3::new(3.0, 4.0, 0.0); - ellipse.minor_axis_ratio = 0.4; - let handle = app.tabs[app.active_tab] - .scene - .add_entity(acadrust::EntityType::Ellipse(ellipse)); - - let _ = app.apply_cmd_result(CmdResult::AddHorizontalConstraint { - kind: crate::scene::parametric_constraints::ConstraintKind::Horizontal, - selection: crate::command::HorizontalConstraintSelection::Reference( - ParametricRef::ellipse_minor_axis(handle), - ), - direction: acadrust::types::Vector3::UNIT_X, - label: "Horizontal constraint", - }); - - let ellipse = match app.tabs[app.active_tab].scene.document.get_entity(handle) { - Some(acadrust::EntityType::Ellipse(ellipse)) => ellipse, - other => panic!("expected an Ellipse, got {other:?}"), - }; - assert!((ellipse.center - acadrust::types::Vector3::new(3.0, 7.0, 0.0)).length() < 1.0e-9); - assert!(ellipse.major_axis.x.abs() < 1.0e-7); - assert!((ellipse.major_axis.length() - 5.0).abs() < 1.0e-7); - assert!((ellipse.minor_axis_ratio - 0.4).abs() < 1.0e-9); - } - - /// Drawing commands must not create parametric constraints implicitly. - #[test] - fn drawing_a_line_onto_an_existing_endpoint_does_not_add_a_constraint() { - let mut app = OpenCADStudio::new_for_test(); - let _ = app.automation_op(r#"{"op":"new"}"#); - let _existing = add_line(&mut app, 0.0, 0.0, 5.0, 0.0); - - let new_line = acadrust::EntityType::Line(acadrust::entities::Line::from_points( - acadrust::types::Vector3::new(5.0, 0.0, 0.0), // exactly on `existing`'s end point - acadrust::types::Vector3::new(10.0, 0.0, 0.0), - )); - app.tabs[app.active_tab].active_cmd = Some(Box::new( - crate::modules::draw::draw::line::LineCommand::new(), - )); - let _ = app.apply_cmd_result(CmdResult::CommitEntity(new_line)); - - assert_eq!( - app.tabs[app.active_tab] - .scene - .parametric_constraint_set(ParametricScope::ModelSpace) - .map(|s| s.constraints.len()) - .unwrap_or(0), - 0, - "a drawing command must not add an implicit constraint" - ); - } - - /// Conflict resolution removes one flagged constraint and is undoable. - #[test] - fn resolve_one_parametric_conflict_removes_a_flagged_constraint_and_is_undoable() { - let mut app = OpenCADStudio::new_for_test(); - let _ = app.automation_op(r#"{"op":"new"}"#); - let line = add_line(&mut app, 0.0, 0.0, 10.0, 3.0); - - let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { - kind: ConstraintKind::Horizontal, - refs: vec![ParametricRef::whole(line)], - driving_param: None, - label: "Horizontal constraint", - }); - let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { - kind: ConstraintKind::Horizontal, - refs: vec![ParametricRef::whole(line)], - driving_param: None, - label: "Horizontal constraint", - }); // exact duplicate — flagged redundant once bumped - app.tabs[app.active_tab] - .scene - .bump_entities(&[(line, crate::scene::ChangeKind::Modified)]); - - let set = app.tabs[app.active_tab] - .scene - .parametric_constraint_set(ParametricScope::ModelSpace) - .unwrap(); - assert_eq!(set.constraints.len(), 2); - assert_eq!( - set.conflicts.len(), - 1, - "the duplicate should have been flagged" - ); - - app.resolve_one_parametric_conflict(); - let set = app.tabs[app.active_tab] - .scene - .parametric_constraint_set(ParametricScope::ModelSpace) - .unwrap(); - assert_eq!( - set.constraints.len(), - 1, - "one of the two duplicates should have been removed" - ); - - app.undo_steps(1); - let set = app.tabs[app.active_tab] - .scene - .parametric_constraint_set(ParametricScope::ModelSpace) - .unwrap(); - assert_eq!( - set.constraints.len(), - 2, - "undo should restore the removed constraint" - ); - } - - #[test] - fn deleting_one_parametric_constraint_is_undoable() { - let mut app = OpenCADStudio::new_for_test(); - let _ = app.automation_op(r#"{"op":"new"}"#); - let line = add_line(&mut app, 0.0, 0.0, 10.0, 3.0); - let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { - kind: ConstraintKind::Horizontal, - refs: vec![ParametricRef::whole(line)], - driving_param: None, - label: "Horizontal constraint", - }); - let id = app.tabs[app.active_tab] - .scene - .parametric_constraint_set(ParametricScope::ModelSpace) - .unwrap() - .constraints[0] - .id; - app.tabs[app.active_tab].scene.selected_constraint = Some(id); - - app.delete_parametric_constraint(id); - assert!(app.tabs[app.active_tab] - .scene - .parametric_constraint_set(ParametricScope::ModelSpace) - .unwrap() - .constraints - .is_empty()); - assert_eq!(app.tabs[app.active_tab].scene.selected_constraint, None); - - app.undo_steps(1); - assert_eq!( - app.tabs[app.active_tab] - .scene - .parametric_constraint_set(ParametricScope::ModelSpace) - .unwrap() - .constraints - .len(), - 1 - ); - } - - /// `named_parameters_design.md` stage 4: applying the PARAMETERS editor's - /// working buffer both defines the parameter and re-solves whatever - /// constraint already references it by name — the entire point of a - /// *named* parameter, not just data plumbing. - #[test] - fn applying_named_parameter_rows_defines_and_resolves_referencing_constraints() { - let mut app = OpenCADStudio::new_for_test(); - let _ = app.automation_op(r#"{"op":"new"}"#); - let line = add_line(&mut app, 0.0, 0.0, 10.0, 0.0); - - // The constraint is added *before* the parameter exists -- allowed, - // same as `ParameterTable::set`'s own forward-reference tolerance; - // it just doesn't resolve to anything until the parameter is defined. - let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { - kind: ConstraintKind::Distance, - refs: vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], - driving_param: Some(crate::scene::named_parameters::DrivingValue::Named( - "target_len".to_string(), - )), - label: "Distance constraint", - }); - - app.named_parameter_editor_rows = - vec![crate::ui::window::named_parameters::ParamEditorRow { - name: "target_len".to_string(), - formula: "8".to_string(), - }]; - app.apply_named_parameter_editor_rows(); - - assert_eq!( - app.tabs[app.active_tab] - .scene - .named_parameters() - .resolve("target_len"), - Ok(8.0) - ); - let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) { - Some(acadrust::EntityType::Line(l)) => (l.start, l.end), - other => panic!("expected a Line, got {other:?}"), - }; - let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); - assert!( - (len - 8.0).abs() < 1e-6, - "length should track the applied parameter, got {len}" - ); - - // Redefining the parameter and re-applying should ripple again. - app.named_parameter_editor_rows[0].formula = "3".to_string(); - app.apply_named_parameter_editor_rows(); - let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) { - Some(acadrust::EntityType::Line(l)) => (l.start, l.end), - other => panic!("expected a Line, got {other:?}"), - }; - let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); - assert!( - (len - 3.0).abs() < 1e-6, - "length should track the redefined parameter, got {len}" - ); - - app.undo_steps(1); - assert_eq!( - app.tabs[app.active_tab] - .scene - .named_parameters() - .resolve("target_len"), - Ok(8.0) - ); - let length_after_undo = match app.tabs[app.active_tab].scene.document.get_entity(line) { - Some(acadrust::EntityType::Line(line)) => line.length(), - other => panic!("expected a Line, got {other:?}"), - }; - assert!((length_after_undo - 8.0).abs() < 1e-6); - - app.redo_steps(1); - assert_eq!( - app.tabs[app.active_tab] - .scene - .named_parameters() - .resolve("target_len"), - Ok(3.0) - ); - } - - /// The no-selection page must expose the active drawing's named parameters. - #[test] - fn no_selection_properties_exposes_named_parameters() { - let mut app = OpenCADStudio::new_for_test(); - let _ = app.automation_op(r#"{"op":"new"}"#); - app.tabs[app.active_tab] - .scene - .named_parameters_mut() - .set("width", "12") - .unwrap(); - - app.refresh_properties(); - - assert!(app.tabs[app.active_tab] - .properties - .sections - .iter() - .flat_map(|section| §ion.props) - .any(|property| matches!( - &property.value, - crate::scene::model::object::PropValue::ParamRow { name, .. } - if name == "width" - ))); - assert!(app.tabs[app.active_tab] - .properties - .sections - .iter() - .flat_map(|section| §ion.props) - .any(|property| matches!( - property.value, - crate::scene::model::object::PropValue::ParamsVisibilityToggle(_) - ))); - } - - /// End-to-end through the actual `Message` handlers a Properties-panel - /// click/keystroke would fire — not `apply_named_parameter_editor_rows` - /// directly — covering the whole Parameters-section lifecycle: add, - /// rename, redefine, and confirm a referencing constraint re-solves - /// after each commit (not just after a whole-table Apply, since this - /// path commits per field). - #[test] - fn properties_panel_parameter_row_add_rename_redefine_and_delete() { - use crate::ui::window::named_parameters::ParamField; - - let mut app = OpenCADStudio::new_for_test(); - let _ = app.automation_op(r#"{"op":"new"}"#); - let line = add_line(&mut app, 0.0, 0.0, 10.0, 0.0); - let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { - kind: ConstraintKind::Distance, - refs: vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], - driving_param: Some(crate::scene::named_parameters::DrivingValue::Named( - "len".to_string(), - )), - label: "Distance constraint", - }); - - // Add: the first row is auto-named "param1". - let _ = app.update(Message::PropParamAddNew); - assert!(app.tabs[app.active_tab] - .scene - .named_parameters() - .contains("param1")); - let index = app.tabs[app.active_tab] - .scene - .named_parameters() - .iter() - .position(|p| p.name == "param1") - .unwrap(); - - // Rename param1 -> len (commit-on-submit, not per keystroke: input - // alone must not touch the table yet). - let _ = app.update(Message::PropParamInput { - index, - field: ParamField::Name, - value: "len".to_string(), - }); - assert!( - app.tabs[app.active_tab] - .scene - .named_parameters() - .contains("param1"), - "typing alone must not commit" - ); - let _ = app.update(Message::PropParamCommit { - index, - field: ParamField::Name, - }); - assert!(!app.tabs[app.active_tab] - .scene - .named_parameters() - .contains("param1")); - assert!(app.tabs[app.active_tab] - .scene - .named_parameters() - .contains("len")); - - // Redefine its formula to 8 and confirm the Distance constraint - // (already referencing "len" by name, defined before this) re-solves. - let _ = app.update(Message::PropParamInput { - index, - field: ParamField::Formula, - value: "8".to_string(), - }); - let _ = app.update(Message::PropParamCommit { - index, - field: ParamField::Formula, - }); - assert_eq!( - app.tabs[app.active_tab] - .scene - .named_parameters() - .resolve("len"), - Ok(8.0) - ); - let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) { - Some(acadrust::EntityType::Line(l)) => (l.start, l.end), - other => panic!("expected a Line, got {other:?}"), - }; - let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); - assert!( - (len - 8.0).abs() < 1e-6, - "length should track the redefined parameter, got {len}" - ); - - // Delete: the row is gone from the table. - let _ = app.update(Message::PropParamDelete(index)); - assert!(!app.tabs[app.active_tab] - .scene - .named_parameters() - .contains("len")); - } - - /// A Constraints-section row click selects every entity the constraint - /// references, replacing whatever was selected before. - #[test] - fn properties_panel_constraint_link_click_selects_its_entities() { - let mut app = OpenCADStudio::new_for_test(); - let _ = app.automation_op(r#"{"op":"new"}"#); - let a = add_line(&mut app, 0.0, 0.0, 10.0, 0.0); - let b = add_line(&mut app, 0.0, 5.0, 10.0, 5.0); - let unrelated = add_line(&mut app, 20.0, 20.0, 30.0, 20.0); - let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { - kind: ConstraintKind::Parallel, - refs: vec![ParametricRef::whole(a), ParametricRef::whole(b)], - driving_param: None, - label: "Parallel constraint", - }); - app.tabs[app.active_tab] - .scene - .select_entity(unrelated, true); - - let _ = app.update(Message::PropConstraintLinkClick(vec![a, b])); - - let selected: std::collections::HashSet = app.tabs[app.active_tab] - .scene - .selected_entities() - .into_iter() - .map(|(h, _)| h) - .collect(); - assert_eq!(selected, std::collections::HashSet::from([a, b])); - } - - /// A row that fails to validate (here, a formula that doesn't parse) - /// must not silently vanish or corrupt the table: it's reported as an - /// error and simply isn't added, while a valid row alongside it still - /// commits — the same isolate-the-failure treatment - /// `ParameterTable::resolve_all` already gives a bad reference. - #[test] - fn applying_a_row_with_a_malformed_formula_reports_an_error_and_keeps_the_good_row() { - let mut app = OpenCADStudio::new_for_test(); - let _ = app.automation_op(r#"{"op":"new"}"#); - - app.named_parameter_editor_rows = vec![ - crate::ui::window::named_parameters::ParamEditorRow { - name: "good".to_string(), - formula: "10".to_string(), - }, - crate::ui::window::named_parameters::ParamEditorRow { - name: "bad".to_string(), - formula: "1 +".to_string(), - }, - ]; - app.apply_named_parameter_editor_rows(); - - assert_eq!( - app.tabs[app.active_tab] - .scene - .named_parameters() - .resolve("good"), - Ok(10.0) - ); - assert!( - !app.tabs[app.active_tab] - .scene - .named_parameters() - .contains("bad"), - "a malformed row must not be added" - ); - assert!( - app.command_line - .history - .iter() - .any(|e| e.kind == crate::ui::command_line::EntryKind::Error), - "a malformed row must be reported as an error, not silently dropped" - ); - } - - /// Two rows typed with the same name must not silently collapse into - /// "whichever `set` call happens last wins" — both are refused, and an - /// unrelated row still commits normally. - #[test] - fn applying_rows_with_a_duplicate_name_refuses_both_and_keeps_the_unrelated_row() { - let mut app = OpenCADStudio::new_for_test(); - let _ = app.automation_op(r#"{"op":"new"}"#); - - app.named_parameter_editor_rows = vec![ - crate::ui::window::named_parameters::ParamEditorRow { - name: "x".to_string(), - formula: "1".to_string(), - }, - crate::ui::window::named_parameters::ParamEditorRow { - name: "x".to_string(), - formula: "2".to_string(), - }, - crate::ui::window::named_parameters::ParamEditorRow { - name: "y".to_string(), - formula: "3".to_string(), - }, - ]; - app.apply_named_parameter_editor_rows(); - - assert!( - !app.tabs[app.active_tab] - .scene - .named_parameters() - .contains("x"), - "a duplicated name must not be added at all" - ); - assert_eq!( - app.tabs[app.active_tab] - .scene - .named_parameters() - .resolve("y"), - Ok(3.0) - ); - assert!(app - .command_line - .history - .iter() - .any(|e| e.kind == crate::ui::command_line::EntryKind::Error)); - } - - /// Two endpoint picks resolve to a Coincident constraint. - #[test] - fn coincident_command_resolves_two_picked_points_into_a_constraint() { - let mut app = OpenCADStudio::new_for_test(); - let _ = app.automation_op(r#"{"op":"new"}"#); - let a = add_line(&mut app, 0.0, 0.0, 5.0, 0.0); - let b = add_line(&mut app, 20.0, 0.0, 20.0, 5.0); // deliberately far from `a`, so the two picks are unambiguous - - let _ = app.apply_cmd_result(CmdResult::AddCoincidentConstraint { - first: crate::command::CoincidentPick { - handle: Some(a), - point: glam::DVec3::new(5.0, 0.0, 0.0), - whole_curve: false, - }, - second: crate::command::CoincidentPick { - handle: Some(b), - point: glam::DVec3::new(20.0, 0.0, 0.0), - whole_curve: false, - }, - multiple: false, - label: "Coincident constraint", - }); - - let set = app.tabs[app.active_tab] - .scene - .parametric_constraint_set(ParametricScope::ModelSpace) - .expect("a constraint set should exist"); - assert_eq!(set.constraints.len(), 1); - let c = &set.constraints[0]; - assert_eq!(c.kind, ConstraintKind::Coincident); - let entities: Vec = c.refs.iter().map(|r| r.entity).collect(); - assert!( - entities.contains(&a) && entities.contains(&b), - "the constraint should reference both lines" - ); - let Some(acadrust::EntityType::Line(first)) = - app.tabs[app.active_tab].scene.document.get_entity(a) - else { - panic!("expected first line"); - }; - let Some(acadrust::EntityType::Line(second)) = - app.tabs[app.active_tab].scene.document.get_entity(b) - else { - panic!("expected second line"); - }; - assert!( - (first.end - acadrust::types::Vector3::new(5.0, 0.0, 0.0)).length() < 1.0e-7 - ); - assert!((second.start - first.end).length() < 1.0e-7); - assert!((second.length() - 5.0).abs() < 1.0e-7); - } - - /// A pick that doesn't land near any real point (no object snap match) - /// must not add a constraint — it should report an error and leave the - /// scope untouched rather than silently constraining the wrong thing. - #[test] - fn coincident_command_reports_an_error_when_a_pick_misses() { - let mut app = OpenCADStudio::new_for_test(); - let _ = app.automation_op(r#"{"op":"new"}"#); - let _a = add_line(&mut app, 0.0, 0.0, 5.0, 0.0); - - let _ = app.apply_cmd_result(CmdResult::AddCoincidentConstraint { - first: crate::command::CoincidentPick { - handle: Some(_a), - point: glam::DVec3::new(5.0, 0.0, 0.0), - whole_curve: false, - }, - second: crate::command::CoincidentPick { - handle: None, - point: glam::DVec3::new(500.0, 500.0, 0.0), - whole_curve: false, - }, - multiple: false, - label: "Coincident constraint", - }); - - assert_eq!( - app.tabs[app.active_tab] - .scene - .parametric_constraint_set(ParametricScope::ModelSpace) - .map(|s| s.constraints.len()) - .unwrap_or(0), - 0, - "a missed pick must not add a constraint" - ); - } -} - -#[cfg(all(test, not(target_arch = "wasm32")))] -mod delobj_tests { - use super::*; - use acadrust::types::Vector3; - - fn sweep_source_presence(value: i16, mode: crate::command::ExtrudeMode) -> (bool, bool) { - let mut app = OpenCADStudio::new_for_test(); - app.automation_op(r#"{"op":"new"}"#); - let i = app.active_tab; - let mut circle = acadrust::Circle::new(); - circle.radius = 2.0; - let profile = app.tabs[i] - .scene - .add_entity(acadrust::EntityType::Circle(circle)); - let path = - app.tabs[i] - .scene - .add_entity(acadrust::EntityType::Line(acadrust::Line::from_points( - Vector3::new(0.0, 0.0, 0.0), - Vector3::new(0.0, 0.0, 5.0), - ))); - app.delete_objects = value; - - let _ = app.apply_cmd_result(CmdResult::SweepEntities { - handles: vec![profile], - path_handle: path, - mode, - options: crate::command::SweepOptions::default(), - color: [1.0; 4], - }); - - let created = app.tabs[i] - .scene - .document - .entities() - .any(|entity| match mode { - crate::command::ExtrudeMode::Solid => { - matches!(entity, acadrust::EntityType::Solid3D(_)) - } - crate::command::ExtrudeMode::Surface => { - matches!(entity, acadrust::EntityType::Surface(_)) - } - }); - assert!(created, "SWEEP must produce the requested result"); - ( - app.tabs[i].scene.document.get_entity(profile).is_some(), - app.tabs[i].scene.document.get_entity(path).is_some(), - ) - } - - #[test] - fn sweep_applies_all_four_source_deletion_modes() { - let solid_expectations = [(true, true), (false, true), (false, false), (false, false)]; - for (value, expected) in solid_expectations.into_iter().enumerate() { - assert_eq!( - sweep_source_presence(value as i16, crate::command::ExtrudeMode::Solid), - expected, - ); - } - assert_eq!( - sweep_source_presence(2, crate::command::ExtrudeMode::Surface), - (false, false), - ); - assert_eq!( - sweep_source_presence(3, crate::command::ExtrudeMode::Surface), - (false, true), - ); - } -} - -#[cfg(all(test, not(target_arch = "wasm32")))] -mod thicken_tests { - use super::*; - use acadrust::entities::{Surface, SurfaceKind}; - use cadkernel::geom2d::{Circle, Curve, NurbsCurve}; - use cadkernel::space::{Parameterization, Plane}; - - #[test] - fn thicken_preserves_sources_and_round_trips_results_with_one_undo() { - let mut app = OpenCADStudio::new_for_test(); - app.automation_op(r#"{"op":"new"}"#); - let body = cadkernel::brep::planar_region( - Plane::XY, - &[vec![Curve::Circle(Circle { - centre: [0.0; 2], - radius: 3.0, - })]], - ) - .unwrap(); - let source = app.add_surface_model( - acadrust::EntityType::Surface(Surface::new(SurfaceKind::Plane)), - body, - ); - assert!(!source.is_null()); - let i = app.active_tab; - let invalid = app.tabs[i] - .scene - .add_entity(acadrust::EntityType::Surface(Surface::new( - SurfaceKind::Generic, - ))); - let original = - serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(); - let before = app.tabs[i].history.undo_stack.len(); - let _ = app.apply_cmd_result(CmdResult::ThickenEntities { - handles: vec![source], - distance: 0.0, - }); - assert_eq!(app.tabs[i].history.undo_stack.len(), before); - let _ = app.apply_cmd_result(CmdResult::ThickenEntities { - handles: vec![invalid, source], - distance: 2.0, - }); - assert_eq!(app.tabs[i].history.undo_stack.len(), before + 1); - let result = app.tabs[i] - .scene - .document - .entities() - .find_map(|entity| { - matches!(entity, acadrust::EntityType::Solid3D(_)).then_some(entity.common().handle) - }) - .unwrap(); - assert!(app.tabs[i] - .scene - .document - .solid_history_operation(result) - .is_some()); - assert_eq!( - serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(), - original - ); - let expected = - cadkernel::brep::analytic_mass_properties(&app.tabs[i].scene.solid_models[&result]) - .unwrap() - .volume; - let bytes = crate::io::save_to_bytes( - &app.tabs[i].scene.document, - "dwg", - app.tabs[i].scene.document.version, - ) - .unwrap(); - let document = crate::io::load_bytes("thicken.dwg", bytes).unwrap(); - let mut restored = crate::scene::Scene::new(); - restored.document = document; - restored.restore_solid_models(&[result]); - let actual = cadkernel::brep::analytic_mass_properties(&restored.solid_models[&result]) - .unwrap() - .volume; - assert!((expected - actual).abs() < 1e-8); - app.undo_active_tab(); - assert!(app.tabs[i].scene.document.get_entity(result).is_none()); - assert!(app.tabs[i] - .scene - .document - .solid_history_operation(result) - .is_none()); - assert!(app.tabs[i].scene.document.get_entity(source).is_some()); - assert!(app.tabs[i].scene.document.get_entity(invalid).is_some()); - app.redo_active_tab(); - assert!(app.tabs[i].scene.document.get_entity(result).is_some()); - assert!(app.tabs[i] - .scene - .document - .solid_history_operation(result) - .is_some()); - assert_eq!( - serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(), - original - ); - } - - #[test] - fn thicken_creates_a_solid_from_a_free_form_surface() { - let mut app = OpenCADStudio::new_for_test(); - app.automation_op(r#"{"op":"new"}"#); - let profile = NurbsCurve::interpolate( - &[[0.0, 0.0], [0.7, 0.15], [1.3, -0.1], [2.0, 0.0]], - None, - None, - Parameterization::Chord, - ) - .unwrap(); - let body = - cadkernel::brep::extrude_surface(Plane::XY, &[Curve::Nurbs(profile)], [0.0, 0.0, 2.0]) - .unwrap(); - let source = app.add_surface_model( - acadrust::EntityType::Surface(Surface::new(SurfaceKind::Generic)), - body, - ); - let i = app.active_tab; - - let _ = app.apply_cmd_result(CmdResult::ThickenEntities { - handles: vec![source], - distance: 0.15, - }); - - let solids = app.tabs[i] - .scene - .document - .entities() - .filter_map(|entity| { - matches!(entity, acadrust::EntityType::Solid3D(_)).then_some(entity.common().handle) - }) - .collect::>(); - assert_eq!(solids.len(), 1); - let solid = &app.tabs[i].scene.solid_models[&solids[0]]; - assert!(solid.validate().is_empty()); - assert!(solid.edges.iter().all(|(_, edge)| edge.coedges.len() == 2)); - assert!(app.tabs[i] - .scene - .document - .solid_history_operation(solids[0]) - .is_some()); - assert!(app.tabs[i].scene.document.get_entity(source).is_some()); - } -} - -#[cfg(test)] -mod dispatched_command_task_tests { - use super::*; - use crate::command::CmdResult; - use acadrust::entities::ViewportRenderMode as Mode; - - /// `CmdResult::Dispatch` and `CmdResult::Relaunch` run the assembled line - /// through `dispatch_command`, and the Task it returns must reach the - /// runtime: the render mode a VSCURRENT keyword picker chooses is applied - /// by a message that Task carries. - #[test] - fn dispatch_and_relaunch_keep_the_task_the_command_returns() { - let mut app = OpenCADStudio::new_for_test(); - let _ = app.automation_op(r#"{"op":"new"}"#); - let i = app.active_tab; - - app.tabs[i].render_mode = Mode::Wireframe2D; - let task = app.apply_cmd_result(CmdResult::Dispatch("VSCURRENT GOURAUDSHADED".into())); - app.drive_headless_task(task).unwrap(); - assert_eq!( - app.tabs[i].render_mode, - Mode::GouraudShaded, - "Dispatch dropped the dispatched command's task" - ); - - app.tabs[i].render_mode = Mode::Wireframe2D; - let task = app.apply_cmd_result(CmdResult::Relaunch( - "VSCURRENT GOURAUDSHADED".into(), - Vec::new(), - )); - app.drive_headless_task(task).unwrap(); - assert_eq!( - app.tabs[i].render_mode, - Mode::GouraudShaded, - "Relaunch dropped the dispatched command's task" - ); - } -} diff --git a/src/app/command_driver/clipboard.rs b/src/app/command_driver/clipboard.rs new file mode 100644 index 000000000..691ad695d --- /dev/null +++ b/src/app/command_driver/clipboard.rs @@ -0,0 +1,648 @@ +use super::*; + +impl OpenCADStudio { + pub(super) fn handle_copy_to_clipboard(&mut self, handles: Vec, base: glam::DVec3) { + let i = self.active_tab; + let count = self.copy_entities_to_clipboard(i, &handles, base); + self.command_line + .push_info(crate::tf!("{} object(s) copied to clipboard.", count).as_ref()); + let prompt = self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.prompt()); + if let Some(prompt) = prompt { + self.command_line.push_info(&prompt); + } + self.command_line.set_step_options( + self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.options()) + .unwrap_or_default(), + ); + if !self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.entity_pick_highlights_hover()) + { + self.tabs[i].scene.set_hover_highlight(None); + } + self.sync_dyn_fields(); + self.refresh_area_preview(i); + } + + pub(super) fn handle_batch_copy( + &mut self, + mut handles: Vec, + transforms: Vec, + ) -> Option> { + let i = self.active_tab; + handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + if handles.is_empty() { + self.tabs[i].active_cmd = None; + return Some(Task::none()); + } + let label = self.history_label_from_active_cmd(i, "ARRAY"); + let count = transforms.len(); + // Same gate as COPY (dimension-free), sized by the total number + // of copies the array will add. + let delta_safe = self.delta_copy_safe(i, &handles); + let pending = self.begin_undo(i, label.clone(), handles.len() * count, delta_safe); + for t in &transforms { + self.tabs[i].scene.copy_entities(&handles, t); + } + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + let noun = if count == 1 { "copy" } else { "copies" }; + self.command_line + .push_output(crate::tf!("{label}: {count} {noun} created.").as_ref()); + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + None + } + + pub(super) fn handle_replace_many( + &mut self, + replacements: Vec<(Handle, Vec)>, + additions: Vec, + ) -> Option> { + let i = self.active_tab; + if let Some((handle, _)) = replacements + .iter() + .find(|(handle, _)| self.tabs[i].scene.is_layer_locked(*handle)) + { + self.reject_locked_edit(i, *handle); + self.tabs[i].active_cmd = None; + return Some(Task::none()); + } + let label = self.history_label_from_active_cmd(i, "FILLET"); + let was_catchment = self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|c| c.name() == "SS_CATCHMENT"); + self.push_undo_snapshot(i, label); + for (handle, entities) in replacements { + self.replace_command_entity(i, handle, entities); + } + for entity in additions { + self.tabs[i].scene.add_entity(entity); + } + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + if was_catchment { + self.command_line + .push_info(crate::t!("Catchment tagged successfully.").as_ref()); + } + self.refresh_properties(); + None + } + + pub(super) fn handle_replace_many_continue( + &mut self, + replacements: Vec<(Handle, Vec)>, + ) -> Option> { + let i = self.active_tab; + if let Some((handle, _)) = replacements + .iter() + .find(|(handle, _)| self.tabs[i].scene.is_layer_locked(*handle)) + { + self.reject_locked_edit(i, *handle); + return Some(Task::none()); + } + let label = self.history_label_from_active_cmd(i, "TRIM"); + self.push_undo_snapshot(i, label); + for (handle, entities) in replacements { + let new_handles = self.replace_command_entity(i, handle, entities); + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + command.on_entity_replaced(handle, &new_handles); + } + } + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].snap_result = None; + if let Some(prompt) = self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.prompt()) + { + self.command_line.push_info(&prompt); + } + self.refresh_properties(); + None + } + + pub(super) fn handle_replace_entity( + &mut self, + handle: Handle, + new_entities: Vec, + ) -> Option> { + let i = self.active_tab; + if self.reject_locked_edit(i, handle) { + return Some(Task::none()); + } + // Detect SPLINEDIT sentinel: a single XLine with a magic layer name. + if new_entities.len() == 1 { + if let acadrust::EntityType::XLine(ref xl) = new_entities[0] { + let op = xl.common.layer.clone(); + if op.starts_with("__SPLINEDIT_") { + let label = self.history_label_from_active_cmd(i, "SPLINEDIT"); + self.push_undo_snapshot(i, label); + crate::modules::draw::modify::splinedit::apply_spline_op( + &mut self.tabs[i].scene.document, + handle, + &op, + ); + self.tabs[i].dirty = true; + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + return Some(Task::none()); + } + } + } + let label = self.history_label_from_active_cmd(i, "TRIM"); + self.push_undo_snapshot(i, label); + self.tabs[i].scene.erase_entities(&[handle]); + let new_handles: Vec = new_entities + .into_iter() + .map(|e| self.tabs[i].scene.add_entity(e)) + .collect(); + // Rebuild replaced dimensions from edited data. + for &nh in &new_handles { + if matches!( + self.tabs[i].scene.document.get_entity(nh), + Some(acadrust::EntityType::Dimension(_)) + ) { + self.tabs[i].scene.invalidate_dim_block_recorded(nh); + } + } + let pedit_entities = if self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.name() == "PEDIT") + { + new_handles + .iter() + .filter_map(|new| self.tabs[i].scene.document.get_entity(*new).cloned()) + .collect::>() + } else { + Vec::new() + }; + if let Some(cmd) = &mut self.tabs[i].active_cmd { + cmd.on_entity_replaced(handle, &new_handles); + for entity in pedit_entities { + cmd.inject_picked_entity(entity); + } + } + self.tabs[i].dirty = true; + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + None + } + + pub(super) fn handle_paste_clipboard(&mut self, base_pt: glam::DVec3) { + let i = self.active_tab; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + if self.clipboard.is_empty() { + self.command_line + .push_error(crate::t!("Clipboard is empty.").as_ref()); + } else { + let delta = base_pt - self.clipboard_base; + let translate = crate::command::EntityTransform::Translate(delta); + self.push_undo_snapshot(i, "PASTECLIP"); + let count = self.clipboard.len(); + let by_index = self.finalize_paste(i, Some(translate)); + self.tabs[i].scene.deselect_all(); + for h in by_index.iter().copied().filter(|h| !h.is_null()) { + self.tabs[i].scene.select_entity(h, false); + } + self.tabs[i].dirty = true; + // Surface any layers the paste brought in (cross-drawing) + // in the layer manager and the layer dropdown. + self.refresh_layer_panel(); + self.refresh_properties(); + self.command_line + .push_info(crate::tf!("{count} object(s) pasted.").as_ref()); + } + } + + pub(crate) fn merge_clipboard_deps(&mut self, i: usize) { + let deps = self.clipboard_deps.clone(); + self.merge_dependencies(i, &deps); + } + + /// Recreate any block definition the pasted INSERTs reference but tab + /// `i`'s document lacks (cross-drawing paste), so the block reference + /// renders its geometry instead of nothing. No-op for same-document + /// pastes. (#135) + pub(crate) fn merge_clipboard_blocks(&mut self, i: usize) { + if self.clipboard_deps.blocks.is_empty() { + return; + } + let blocks = self.clipboard_deps.blocks.clone(); + for def in blocks { + if self.tabs[i] + .scene + .document + .block_records + .get(&def.name) + .is_some() + { + continue; + } + self.tabs[i] + .scene + .define_block_raw(&def.name, def.base_point, def.entities); + } + } + + /// Shared paste finalize for every paste path (PASTECLIP, PASTEORIG): + /// recreate the clipboard's dependency records and block definitions, add + /// each entity with fresh handles (optionally transformed), recreate each + /// entity's xdictionary graph (XCLIP filters etc.), and tessellate pasted + /// solids. Returns the new handles, index-aligned with the clipboard + /// (NULL where an add failed). Keeping this in one place means a new + /// cross-drawing concern is wired once, not re-implemented per command. + pub(crate) fn finalize_paste( + &mut self, + i: usize, + translate: Option, + ) -> Vec { + self.merge_clipboard_deps(i); + self.merge_clipboard_blocks(i); + let by_index: Vec = self + .clipboard + .clone() + .into_iter() + .map(|mut entity| { + if let Some(t) = &translate { + crate::scene::view::dispatch::apply_transform(&mut entity, t); + } + // A dimension draws from its baked `*D` block (baked in WCS), so + // give the paste its own transformed copy of that block and + // re-point it — otherwise the pasted dimension renders at the + // source location instead of the paste point. The block was + // snapshotted into the clipboard at copy time, so this works + // cross-drawing too. Mirrors the in-drawing copy. (#290, #161) + if let acadrust::EntityType::Dimension(d) = &entity { + let bn = d.base().block_name.clone(); + if !bn.trim().is_empty() { + let subs = self + .clipboard_deps + .dim_blocks + .iter() + .find(|b| b.name.eq_ignore_ascii_case(&bn)) + .map(|def| def.entities.clone()); + if let Some(subs) = subs { + let bt = translate.clone().unwrap_or( + crate::command::EntityTransform::Translate(glam::DVec3::ZERO), + ); + if let Some(new_bn) = + self.tabs[i].scene.define_transformed_block(&subs, &bt) + { + if let acadrust::EntityType::Dimension(d) = &mut entity { + d.base_mut().block_name = new_bn; + } + } + } + } + } + self.tabs[i].scene.add_entity_clone(entity) + }) + .collect(); + let annotation_delta = match translate { + Some(crate::command::EntityTransform::Translate(delta)) => delta, + _ => glam::DVec3::ZERO, + }; + self.merge_clipboard_ext_objects(i, &by_index, annotation_delta); + // Source handles stored in the clipboard map one-to-one to the freshly + // pasted handles. Use that map to reconnect LEADER -> copied annotation. + let mut handle_map = rustc_hash::FxHashMap::default(); + + for (source, &copied) in self.clipboard.iter().zip(by_index.iter()) { + if !copied.is_null() { + handle_map.insert(source.common().handle, copied); + } + } + + let leader_links: Vec<(Handle, Handle)> = self + .clipboard + .iter() + .filter_map(|source| { + let acadrust::EntityType::Leader(leader) = source else { + return None; + }; + + let copied_leader = handle_map.get(&source.common().handle).copied()?; + let copied_annotation = handle_map + .get(&leader.annotation_handle) + .copied() + .unwrap_or(Handle::NULL); + + Some((copied_leader, copied_annotation)) + }) + .collect(); + + for (leader_handle, annotation_handle) in leader_links { + if let Some(acadrust::EntityType::Leader(leader)) = + self.tabs[i].scene.document.get_entity_mut(leader_handle) + { + leader.annotation_handle = annotation_handle; + } + + let _ = self.tabs[i] + .scene + .sync_displayed_annotation_context(leader_handle); + } + // Recreate any group whose whole membership was copied, so a pasted + // group stays grouped — cross-drawing too, since the groups were + // snapshotted into the clipboard at copy time. `by_index` is aligned + // with `self.clipboard`, so the source handle of each pasted entity maps + // its clipboard clone to its new handle. Same shared `recreate_groups` + // the in-drawing COPY path uses. (#440) + if !self.clipboard_deps.groups.is_empty() { + let groups = self.clipboard_deps.groups.clone(); + self.tabs[i].scene.recreate_groups(groups, &handle_map); + } + // Constraints wholly within the pasted selection follow it. + self.tabs[i] + .scene + .duplicate_parametric_constraints_for(&handle_map); + // Incremental: `add_entity` already tessellated every pasted top-level + // solid, and existing document solids are still cached — so only newly + // introduced block-definition solids need building. The full rebuild + // would clear and re-tessellate the entire document (every solid in the + // drawing) on each paste, which is what made a large paste stall. + self.tabs[i].scene.populate_missing_meshes_from_document(); + by_index + } + + /// Recreate the extension-dictionary object graph (XCLIP spatial filters, + /// attached XRecords, …) captured for each copied entity, cloning every + /// object into this document with fresh handles, remapping all internal + /// references, and re-pointing the pasted entity's `xdictionary_handle` at + /// the new root. `by_index` is the paste's new entity handles, aligned with + /// the clipboard order (NULL where the add failed). No-op without captures. + pub(crate) fn merge_clipboard_ext_objects( + &mut self, + i: usize, + by_index: &[Handle], + annotation_delta: glam::DVec3, + ) { + if self.clipboard_deps.ext_objects.is_empty() { + return; + } + let captures = self.clipboard_deps.ext_objects.clone(); + let doc = &mut self.tabs[i].scene.document; + for cap in &captures { + let Some(&new_entity) = by_index.get(cap.entity_index) else { + continue; + }; + if new_entity.is_null() { + continue; + } + if let Some(new_root) = recreate_ext_subtree(doc, cap, Some(new_entity)) { + if let Some(e) = doc.get_entity_mut(new_entity) { + e.common_mut().xdictionary_handle = Some(new_root); + } + crate::scene::annotative::translate_annotation_contexts( + doc, + new_entity, + annotation_delta, + ); + } + } + // The wires were tessellated before the filters existed; refresh only + // the freshly-pasted entities whose clip object graph was attached. + let changes: Vec<_> = by_index + .iter() + .copied() + .filter(|handle| !handle.is_null()) + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); + } + + /// Recreate the captured xdictionary subtrees in this document (fresh + /// handles, remapped references) WITHOUT an added host entity, returning + /// `entity_index → new xdictionary root`. Used by PASTEBLOCK, which folds + /// the clipboard into a new block definition: the caller stamps each new + /// root onto the matching entity's `xdictionary_handle` before defining the + /// block, so the block's nested insert keeps its XCLIP filter. + pub(crate) fn recreate_clipboard_ext_roots( + &mut self, + i: usize, + ) -> std::collections::HashMap { + let mut out = std::collections::HashMap::new(); + if self.clipboard_deps.ext_objects.is_empty() { + return out; + } + let captures = self.clipboard_deps.ext_objects.clone(); + let doc = &mut self.tabs[i].scene.document; + for cap in &captures { + if let Some(new_root) = recreate_ext_subtree(doc, cap, None) { + out.insert(cap.entity_index, new_root); + } + } + out + } +} + +fn recreate_ext_subtree( + doc: &mut acadrust::CadDocument, + cap: &crate::app::ClipExtObjects, + entity_handle: Option, +) -> Option { + use std::collections::HashMap; + let mut remap: HashMap = HashMap::new(); + if let Some(eh) = entity_handle { + remap.insert(cap.src_entity_handle, eh); + } + for (old, scale) in &cap.annotation_scales { + let target = crate::scene::annotative::ensure_scale_object(doc, scale); + remap.insert(*old, target); + } + for (old, _) in &cap.objects { + remap.insert(*old, doc.allocate_handle()); + } + for (old, obj) in &cap.objects { + let mut obj = obj.clone(); + let new_h = remap[old]; + remap_object(&mut obj, new_h, &remap); + doc.objects.insert(new_h, obj); + } + remap.get(&cap.root).copied() +} + +/// Replace references to a clipboard entity inside one recreated extension +/// dictionary graph after its final block-owned handle becomes known. +pub(crate) fn remap_ext_subtree_reference( + doc: &mut acadrust::CadDocument, + root: Handle, + source_entity: Handle, + target_entity: Handle, +) { + use acadrust::objects::ObjectType; + use rustc_hash::FxHashSet; + use std::collections::HashMap; + + let remap = HashMap::from([(source_entity, target_entity)]); + let mut seen = FxHashSet::default(); + let mut pending = vec![root]; + while let Some(handle) = pending.pop() { + if handle.is_null() || !seen.insert(handle) { + continue; + } + let children = match doc.objects.get(&handle) { + Some(ObjectType::Dictionary(dictionary)) => { + let mut children: Vec<_> = + dictionary.entries.iter().map(|(_, child)| *child).collect(); + if let Some(extension) = dictionary.xdictionary_handle { + children.push(extension); + } + children + } + Some(ObjectType::DictionaryWithDefault(dictionary)) => { + let mut children: Vec<_> = + dictionary.entries.iter().map(|(_, child)| *child).collect(); + children.push(dictionary.default_handle); + children + } + _ => Vec::new(), + }; + pending.extend(children); + if let Some(mut object) = doc.objects.remove(&handle) { + remap_object(&mut object, handle, &remap); + doc.objects.insert(handle, object); + } + } +} + +/// Rewrite a cloned extension-dictionary object onto fresh handles: set its own +/// handle to `new_handle` and remap its owner and any handle references it holds +/// through `remap` (a handle still in the source space stays unchanged, which is +/// correct for cross-references that point outside the captured subtree). +fn remap_object( + obj: &mut acadrust::objects::ObjectType, + new_handle: acadrust::Handle, + remap: &std::collections::HashMap, +) { + use acadrust::objects::ObjectType; + let map = |h: acadrust::Handle| remap.get(&h).copied().unwrap_or(h); + match obj { + ObjectType::Dictionary(d) => { + d.handle = new_handle; + d.owner = map(d.owner); + for (_, h) in d.entries.iter_mut() { + *h = map(*h); + } + if let Some(x) = d.xdictionary_handle.as_mut() { + *x = map(*x); + } + for r in d.reactors.iter_mut() { + *r = map(*r); + } + } + ObjectType::DictionaryWithDefault(d) => { + d.handle = new_handle; + d.owner = map(d.owner); + for (_, h) in d.entries.iter_mut() { + *h = map(*h); + } + d.default_handle = map(d.default_handle); + } + ObjectType::DictionaryVariable(v) => { + v.handle = new_handle; + v.owner_handle = map(v.owner_handle); + } + ObjectType::SpatialFilter(s) => { + s.handle = new_handle; + s.owner = map(s.owner); + } + ObjectType::XRecord(x) => { + x.handle = new_handle; + x.owner = map(x.owner); + for entry in &mut x.entries { + if let acadrust::objects::XRecordValue::Handle(handle) = &mut entry.value { + *handle = map(*handle); + } + } + } + ObjectType::Group(g) => { + g.handle = new_handle; + g.owner = map(g.owner); + for h in g.entities.iter_mut() { + *h = map(*h); + } + } + ObjectType::ObjectContextData(context) => { + context.handle = new_handle; + context.owner_handle = map(context.owner_handle); + for reactor in &mut context.reactors { + *reactor = map(*reactor); + } + if let Some(dictionary) = &mut context.xdictionary_handle { + *dictionary = map(*dictionary); + } + context.scale = map(context.scale); + match &mut context.kind { + acadrust::objects::ObjectContextKind::Dim(dimension) => { + dimension.block = map(dimension.block); + } + acadrust::objects::ObjectContextKind::HatchView(hatch) => { + hatch.view = map(hatch.view); + } + acadrust::objects::ObjectContextKind::MTextAttribute(attribute) => { + if let Some(embedded) = &mut attribute.context { + embedded.owner_handle = map(embedded.owner_handle); + for reactor in &mut embedded.reactors { + *reactor = map(*reactor); + } + if let Some(dictionary) = &mut embedded.xdictionary_handle { + *dictionary = map(*dictionary); + } + embedded.scale = map(embedded.scale); + } + } + acadrust::objects::ObjectContextKind::MLeader(mleader) => { + if let Some(handle) = &mut mleader.text_style_handle { + *handle = map(*handle); + } + if let Some(handle) = &mut mleader.block_content_handle { + *handle = map(*handle); + } + if let Some(handle) = &mut mleader.scale_handle { + *handle = map(*handle); + } + for root in &mut mleader.leader_roots { + for line in &mut root.lines { + if let Some(handle) = &mut line.line_type_handle { + *handle = map(*handle); + } + if let Some(handle) = &mut line.arrowhead_handle { + *handle = map(*handle); + } + } + } + } + _ => {} + } + } + // Other leaf object kinds don't appear in an entity xdictionary; if one + // does, it's inserted with the fresh handle below via the caller's key, + // but its internal owner is left as-is (best effort). + _ => {} + } +} diff --git a/src/app/command_driver/constraint.rs b/src/app/command_driver/constraint.rs new file mode 100644 index 000000000..3e7e4c5bf --- /dev/null +++ b/src/app/command_driver/constraint.rs @@ -0,0 +1,1954 @@ +use super::*; + +impl OpenCADStudio { + pub(super) fn handle_open_auto_constrain_settings(&mut self) { + self.auto_constrain_saved = Some(self.auto_constrain_settings.clone()); + self.auto_constrain_selected_row = 0; + self.auto_constrain_distance_input = + format!("{}", self.auto_constrain_settings.distance_tolerance); + self.auto_constrain_angle_input = + format!("{}", self.auto_constrain_settings.angle_tolerance_deg); + self.active_modal = Some(super::super::ModalKind::AutoConstrainSettings); + } + + pub(super) fn handle_add_parametric_constraint(&mut self, result: CmdResult) -> Option> { + let i = self.active_tab; + let CmdResult::AddParametricConstraint { + kind, + refs, + driving_param, + label, + } = result + else { + unreachable!("router only routes the matching variant"); + }; + use crate::scene::parametric_constraints::ConstraintKind; + // GCSMOOTH and FXCONSTRAINT re-prompt after a rejected pick. + let keep_command = self.tabs[i].active_cmd.as_ref().is_some_and(|command| { + (kind == ConstraintKind::Smooth && command.name() == "GCSMOOTH") + || (kind == ConstraintKind::Fixed && command.name() == "FXCONSTRAINT") + }); + if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( + kind, + &refs, + driving_param.as_ref(), + ) { + if !keep_command { + self.tabs[i].active_cmd = None; + } + self.tabs[i].snap_result = None; + self.command_line.push_error(message); + if keep_command { + if let Some(prompt) = + self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()) + { + self.command_line.push_info(&prompt); + } + } + return Some(Task::none()); + } + let scope = self.tabs[i].current_parametric_scope(); + if kind == ConstraintKind::Fixed + && self.tabs[i] + .scene + .parametric_constraint_set(scope) + .is_some_and(|set| { + set.constraints.iter().any(|existing| { + existing.enabled && existing.kind == kind && existing.refs == refs + }) + }) + { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line + .push_error("The constraint already exists on the selected objects."); + return Some(Task::none()); + } + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + let touched: Vec = refs.iter().map(|r| r.entity).collect(); + let pending = self.begin_undo(i, label, touched.len(), true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + let retain_size = self.constraint_solve_mode && driving_param.is_none(); + let id = self.tabs[i].scene.parametric_constraint_set_mut(scope).add( + kind, + refs, + driving_param, + ); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched + .into_iter() + .map(|h| (h, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &[], + retain_size, + ); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line.push_output("Constraint applied."); + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + None + } + + pub(super) fn handle_add_equal_constraint(&mut self, result: CmdResult) -> Option> { + let i = self.active_tab; + let CmdResult::AddEqualConstraint { + first, + others, + multiple, + label, + } = result + else { + unreachable!("router only routes the matching variant"); + }; + use crate::modules::parametric::EqualConstraintCommand; + use crate::scene::parametric_constraints::{ + equal_size, equal_size_follower, ConstraintKind, EqualSize, ParametricRef, + }; + + let scope = self.tabs[i].current_parametric_scope(); + // Enter ends a Multiple flow with the reference's summary line. + let finishing = multiple && others.is_empty(); + let mut followers: Vec = Vec::new(); + for other in others { + let refs = [first, other]; + if other == first + || self + .tabs[i] + .scene + .validate_parametric_constraint(ConstraintKind::Equal, &refs, None) + .is_err() + || equal_size_follower(&self.tabs[i].scene.document, first, other) + .is_none() + { + // The reference names the kind the first object + // asks for. + let message = match equal_size(&self.tabs[i].scene.document, first) { + Some(EqualSize::Radius(_)) => EqualConstraintCommand::INVALID_RADIUS_OBJECT, + _ => EqualConstraintCommand::INVALID_LENGTH_OBJECT, + }; + self.command_line.push_error(message); + continue; + } + let exists = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .is_some_and(|set| { + set.constraints.iter().any(|existing| { + existing.enabled + && existing.kind == ConstraintKind::Equal + && (existing.refs == refs || existing.refs == [other, first]) + }) + }); + if exists { + self.command_line + .push_error("The constraint already exists on the selected objects."); + continue; + } + followers.push(other); + } + // A refused second object is asked for again, as in the + // reference; Multiple keeps asking anyway. + let keep = (multiple && !finishing) || (!multiple && followers.is_empty()); + if keep { + if let Some(prompt) = + self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()) + { + self.command_line.push_info(&prompt); + } + } else { + self.tabs[i].active_cmd = None; + } + self.tabs[i].snap_result = None; + if finishing { + let summary = match equal_size(&self.tabs[i].scene.document, first) { + Some(EqualSize::Radius(_)) => "Radius of objects made equal", + _ => "Length of objects made equal", + }; + self.command_line.push_output(summary); + } + if followers.is_empty() { + return Some(Task::none()); + } + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + let mut touched = vec![first.entity]; + for follower in &followers { + if !touched.contains(&follower.entity) { + touched.push(follower.entity); + } + } + let pending = self.begin_undo(i, label, touched.len(), true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + // The reference resizes the follower in place — its start (a + // circle its center) and direction stay, only its length or + // radius takes the first object's — so do that first and let + // the relation then hold what already fits. + for follower in &followers { + if let Some(resized) = + equal_size_follower(&self.tabs[i].scene.document, first, *follower) + { + self.tabs[i].scene.update_entity(resized); + } + let id = self.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .add(ConstraintKind::Equal, vec![first, *follower], None); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + } + let changes = touched + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect::>(); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &[], + self.constraint_solve_mode, + ); + self.tabs[i].dirty = true; + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + None + } + + pub(super) fn handle_check_constraint_point( + &mut self, + pick: crate::command::CoincidentPick, + ) -> Task { + let i = self.active_tab; + use crate::scene::parametric_constraints::{ + nearest_parametric_point, nearest_parametric_point_on_entity, resolve_point, + }; + let scope = self.tabs[i].current_parametric_scope(); + let resolved = { + let document = &self.tabs[i].scene.document; + let world = + acadrust::types::Vector3::new(pick.point.x, pick.point.y, pick.point.z); + let reference = match pick.handle { + Some(handle) => { + nearest_parametric_point_on_entity(document, scope, handle, world) + } + None => nearest_parametric_point(document, scope, world, None), + }; + reference.map(|reference| { + let point = document + .get_entity(reference.entity) + .zip(reference.marker) + .and_then(|(entity, marker)| resolve_point(entity, marker)) + .map_or(pick.point, |p| glam::DVec3::new(p.x, p.y, p.z)); + (reference, point) + }) + }; + let Some((reference, point)) = resolved else { + return self.apply_cmd_result(CmdResult::ReportError( + crate::modules::parametric::DimConstraintCommand::NO_POINT.to_string(), + )); + }; + let result = self.tabs[i] + .active_cmd + .as_mut() + .map(|command| command.accept_constraint_point(reference, point)); + match result { + Some(result) => self.apply_cmd_result(result), + None => Task::none(), + } + } + + pub(super) fn handle_add_dimensional_constraint(&mut self, result: CmdResult) -> Option> { + let i = self.active_tab; + let CmdResult::AddDimensionalConstraint { + kind, + first, + second, + first_point, + second_point, + location, + axis, + direction, + name, + expression, + renamed, + label, + } = result + else { + unreachable!("router only routes the matching variant"); + }; + use crate::modules::annotate::{aligned_dim, linear_dim}; + use crate::scene::named_parameters::{is_valid_name, DrivingValue}; + use crate::scene::parametric_constraints::{ + distance_direction_type, dynamic_dimension_text, ConstraintKind, + }; + + let scope = self.tabs[i].current_parametric_scope(); + let mut refs = vec![first, second]; + refs.extend(direction); + // The same measurement between the same references is + // refused after the value, and the command ends. + let duplicate = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .is_some_and(|set| { + set.constraints.iter().any(|c| { + c.enabled + && c.kind == kind + && c.refs.len() == refs.len() + && c.refs[..2].contains(&first) + && c.refs[..2].contains(&second) + && c.refs.get(2) == refs.get(2) + }) + }); + if duplicate { + self.command_line + .push_output("The constraint already exists on the selected objects."); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + return Some(Task::none()); + } + // A refused value keeps the prompt open, as the reference's + // editor does. A directed distance validates as the plain + // two-point distance it also is. + let (validate_kind, validate_refs) = if kind == ConstraintKind::DistanceDirected { + (ConstraintKind::Distance, &refs[..2]) + } else { + (kind, &refs[..]) + }; + if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( + validate_kind, + validate_refs, + Some(&DrivingValue::Literal(1.0)), + ) { + self.reprompt_active_command(i, message); + return Some(Task::none()); + } + let anchors = crate::scene::parametric_constraints::dimensional_anchor_refs( + &self.tabs[i].scene.document, + &refs, + ); + let name = name.trim().to_string(); + if !is_valid_name(&name) { + self.command_line.push_error( + "Only alphanumeric names, starting with alpha characters, are allowed.", + ); + self.reprompt_active_command(i, &format!("Invalid parameter name: {name}.")); + return Some(Task::none()); + } + if renamed && self.tabs[i].scene.named_parameters().contains(&name) { + self.reprompt_active_command(i, "Parameter with this name already exists."); + return Some(Task::none()); + } + let mut table = self.tabs[i].scene.named_parameters().clone(); + if let Err(error) = table.set(&name, &expression) { + self.reprompt_active_command(i, &error.to_string()); + return Some(Task::none()); + } + let value = match table.resolve(&name) { + Ok(value) if value.is_finite() => value, + _ => { + self.command_line.push_error("Invalid expression."); + self.reprompt_active_command( + i, + "The parameter is used in an expression which results in an invalid value for a dimensional constraint.", + ); + return Some(Task::none()); + } + }; + // DCFORM Annotational: an ordinary plotted dimension on the + // current layer that shows the value at dimension precision. + let annotational = self.constraint_form_annotational; + let text = dynamic_dimension_text( + &name, + value, + self.tabs[i].scene.constraint_name_format, + false, + Some(&expression), + annotational, + None, + ); + let mut entity = if kind == ConstraintKind::Distance { + aligned_dim::aligned_dimension_entity( + first_point, + second_point, + location, + Some(text), + ) + } else { + linear_dim::linear_dimension_entity( + first_point, + second_point, + location, + axis, + Some(text), + ) + }; + crate::scene::creation_style::apply_current_creation_styles( + &self.tabs[i].scene.document, + &mut entity, + ); + if annotational { + entity + .as_entity_mut() + .set_layer(self.tabs[i].active_layer.clone()); + } else { + // A dynamic dimension lives on the reference's constraints + // layer, hidden from the layer lists and never plotted, and + // draws in the constraint gray, not the current color. + self.tabs[i].scene.ensure_dynamic_dimension_layer(); + entity.as_entity_mut().set_layer( + crate::scene::parametric_constraints::DYNAMIC_DIMENSION_LAYER.to_string(), + ); + entity.common_mut().color = acadrust::types::Color::Rgb { + r: 103, + g: 109, + b: 118, + }; + } + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + let mut touched = vec![first.entity]; + for reference in [Some(second), direction].into_iter().flatten() { + if !touched.contains(&reference.entity) { + touched.push(reference.entity); + } + } + let pending = self.begin_undo(i, label, touched.len() + 1, false); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i].scene.named_parameters = table; + let dimension = self.tabs[i].scene.add_entity(entity); + let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); + let id = set.add(kind, refs, Some(DrivingValue::Named(name))); + if direction.is_some() { + if let Some(constraint) = set.constraints.iter_mut().find(|c| c.id == id) { + constraint.distance_direction_type = + distance_direction_type::PERPENDICULAR_TO_LINE; + } + } + set.dimensions.insert(id, dimension); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + self.tabs[i].scene.attach_dimension_association( + dimension, + vec![Some(first.entity), Some(second.entity)], + ); + let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + // The first point (and a line it belongs to that the distance + // runs perpendicular to) stays; a value other than the + // measured one moves the second, as in the reference. Not + // `retain_size`: the typed value is the new length. + self.tabs[i] + .scene + .bump_entities_with_parametric_policy(&changes, &anchors, false); + // DYNCONSTRAINTDISPLAY 0 also keeps a new dynamic dimension off screen. + self.tabs[i].scene.refresh_hidden_dynamic_dimensions(); + self.tabs[i].scene.refresh_dynamic_dimension_scales(true); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + None + } + + pub(super) fn handle_add_radial_constraint(&mut self, result: CmdResult) -> Option> { + let i = self.active_tab; + let CmdResult::AddRadialConstraint { + circle, + center, + radius, + location, + diameter, + name, + expression, + renamed, + } = result + else { + unreachable!("router only routes the matching variant"); + }; + use crate::modules::annotate::{diameter_dim, radius_dim}; + use crate::scene::named_parameters::{is_valid_name, DrivingValue}; + use crate::scene::parametric_constraints::{ + dynamic_dimension_text, ConstraintKind, ParametricRef, + }; + + let scope = self.tabs[i].current_parametric_scope(); + let kind = if diameter { + ConstraintKind::Diameter + } else { + ConstraintKind::Radius + }; + let refs = vec![circle]; + // A circle already carrying a radius or diameter constraint + // is refused after the value, as the reference does. + let duplicate = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .is_some_and(|set| { + set.constraints.iter().any(|c| { + c.enabled + && matches!( + c.kind, + ConstraintKind::Radius | ConstraintKind::Diameter + ) + && c.refs.iter().any(|r| r.entity == circle.entity) + }) + }); + if duplicate { + self.command_line + .push_output("The constraint already exists on the selected objects."); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + return Some(Task::none()); + } + if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( + kind, + &refs, + Some(&DrivingValue::Literal(1.0)), + ) { + self.reprompt_active_command(i, message); + return Some(Task::none()); + } + let name = name.trim().to_string(); + if !is_valid_name(&name) { + self.command_line.push_error( + "Only alphanumeric names, starting with alpha characters, are allowed.", + ); + self.reprompt_active_command(i, &format!("Invalid parameter name: {name}.")); + return Some(Task::none()); + } + if renamed && self.tabs[i].scene.named_parameters().contains(&name) { + self.reprompt_active_command(i, "Parameter with this name already exists."); + return Some(Task::none()); + } + let mut table = self.tabs[i].scene.named_parameters().clone(); + if let Err(error) = table.set(&name, &expression) { + self.reprompt_active_command(i, &error.to_string()); + return Some(Task::none()); + } + // A circle has no zero or negative size: the value is + // refused and the prompt comes back. + let value = match table.resolve(&name) { + Ok(value) if value.is_finite() && value > 0.0 => value, + _ => { + self.command_line.push_error("Invalid expression."); + self.reprompt_active_command( + i, + "The parameter is used in an expression which results in an invalid value for a dimensional constraint.", + ); + return Some(Task::none()); + } + }; + let annotational = self.constraint_form_annotational; + let text = dynamic_dimension_text( + &name, + value, + self.tabs[i].scene.constraint_name_format, + false, + Some(&expression), + annotational, + None, + ); + let entity = if diameter { + diameter_dim::diameter_constraint_entity(center, radius, location, Some(text)) + } else { + radius_dim::radius_constraint_entity(center, radius, location, Some(text)) + }; + let Some(mut entity) = entity else { + self.reprompt_active_command(i, "No valid constraint point found."); + return Some(Task::none()); + }; + crate::scene::creation_style::apply_current_creation_styles( + &self.tabs[i].scene.document, + &mut entity, + ); + if annotational { + entity + .as_entity_mut() + .set_layer(self.tabs[i].active_layer.clone()); + } else { + self.tabs[i].scene.ensure_dynamic_dimension_layer(); + entity.as_entity_mut().set_layer( + crate::scene::parametric_constraints::DYNAMIC_DIMENSION_LAYER.to_string(), + ); + entity.common_mut().color = acadrust::types::Color::Rgb { + r: 103, + g: 109, + b: 118, + }; + } + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + // The centre stays where it is; the value moves the rim. + let anchors = vec![ParametricRef::center(circle.entity)]; + let pending = self.begin_undo( + i, + if diameter { + "Diameter constraint" + } else { + "Radius constraint" + }, + 2, + false, + ); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i].scene.named_parameters = table; + let dimension = self.tabs[i].scene.add_entity(entity); + let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); + let id = set.add(kind, refs, Some(DrivingValue::Named(name))); + set.dimensions.insert(id, dimension); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + self.tabs[i] + .scene + .attach_dimension_association(dimension, vec![Some(circle.entity)]); + let changes = vec![(circle.entity, crate::scene::ChangeKind::Modified)]; + self.tabs[i] + .scene + .bump_entities_with_parametric_policy(&changes, &anchors, false); + self.tabs[i].scene.refresh_hidden_dynamic_dimensions(); + self.tabs[i].scene.refresh_dynamic_dimension_scales(true); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + None + } + + pub(super) fn handle_add_angular_constraint(&mut self, result: CmdResult) -> Option> { + let i = self.active_tab; + let CmdResult::AddAngularConstraint { + refs, + points, + location, + sector, + name, + expression, + renamed, + } = result + else { + unreachable!("router only routes the matching variant"); + }; + use crate::modules::annotate::angular_dim; + use crate::scene::named_parameters::{is_valid_name, DrivingValue}; + use crate::scene::parametric_constraints::{ + angle_decimals, dynamic_dimension_text, ConstraintKind, ParametricRef, + }; + + let scope = self.tabs[i].current_parametric_scope(); + let kind = if refs.len() == 2 { + ConstraintKind::Angle + } else { + ConstraintKind::Angle3Point + }; + // The same angle between the same sides is refused after the + // value (whichever sector), and the command ends. + let duplicate = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .is_some_and(|set| { + set.constraints.iter().any(|c| { + c.enabled + && c.kind == kind + && c.refs.len() == refs.len() + && if kind == ConstraintKind::Angle { + c.refs.contains(&refs[0]) && c.refs.contains(&refs[1]) + } else { + c.refs == refs + } + }) + }); + if duplicate { + self.command_line + .push_output("The constraint already exists on the selected objects."); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + return Some(Task::none()); + } + if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( + kind, + &refs, + Some(&DrivingValue::Literal(1.0)), + ) { + self.reprompt_active_command(i, message); + return Some(Task::none()); + } + let name = name.trim().to_string(); + if !is_valid_name(&name) { + self.command_line.push_error( + "Only alphanumeric names, starting with alpha characters, are allowed.", + ); + self.reprompt_active_command(i, &format!("Invalid parameter name: {name}.")); + return Some(Task::none()); + } + if renamed && self.tabs[i].scene.named_parameters().contains(&name) { + self.reprompt_active_command(i, "Parameter with this name already exists."); + return Some(Task::none()); + } + let mut table = self.tabs[i].scene.named_parameters().clone(); + if let Err(error) = table.set(&name, &expression) { + self.reprompt_active_command(i, &error.to_string()); + return Some(Task::none()); + } + let value = match table.resolve(&name) { + Ok(value) if value.is_finite() => value, + _ => { + self.command_line.push_error("Invalid expression."); + self.reprompt_active_command( + i, + "The parameter is used in an expression which results in an invalid value for a dimensional constraint.", + ); + return Some(Task::none()); + } + }; + let annotational = self.constraint_form_annotational; + let decimals = angle_decimals(&self.tabs[i].scene.document, None); + let text = dynamic_dimension_text( + &name, + value, + self.tabs[i].scene.constraint_name_format, + false, + Some(&expression), + annotational, + Some(decimals), + ); + let entity = if kind == ConstraintKind::Angle { + angular_dim::angular_two_line_entity( + points[0], points[1], points[2], points[3], location, Some(text), + ) + } else { + angular_dim::angular_three_point_entity( + points[0], points[1], points[2], location, Some(text), + ) + }; + let Some(mut entity) = entity else { + self.reprompt_active_command(i, "No valid constraint point found."); + return Some(Task::none()); + }; + crate::scene::creation_style::apply_current_creation_styles( + &self.tabs[i].scene.document, + &mut entity, + ); + if annotational { + entity + .as_entity_mut() + .set_layer(self.tabs[i].active_layer.clone()); + } else { + self.tabs[i].scene.ensure_dynamic_dimension_layer(); + entity.as_entity_mut().set_layer( + crate::scene::parametric_constraints::DYNAMIC_DIMENSION_LAYER.to_string(), + ); + entity.common_mut().color = acadrust::types::Color::Rgb { + r: 103, + g: 109, + b: 118, + }; + } + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + let mut touched: Vec = Vec::new(); + for reference in &refs { + if !touched.contains(&reference.entity) { + touched.push(reference.entity); + } + } + // The first side (or the vertex and first point) stays; a + // value other than the measured one turns the other side. + let anchors: Vec = + crate::scene::parametric_constraints::dimensional_anchor_refs( + &self.tabs[i].scene.document, + &refs, + ); + let association = if kind == ConstraintKind::Angle { + vec![ + Some(refs[0].entity), + Some(refs[0].entity), + Some(refs[1].entity), + Some(refs[1].entity), + ] + } else { + vec![ + Some(refs[1].entity), + Some(refs[0].entity), + Some(refs[2].entity), + ] + }; + let pending = self.begin_undo(i, "Angular constraint", touched.len() + 1, false); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i].scene.named_parameters = table; + let dimension = self.tabs[i].scene.add_entity(entity); + let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); + let id = set.add(kind, refs, Some(DrivingValue::Named(name))); + if let Some(constraint) = set.constraints.iter_mut().find(|c| c.id == id) { + constraint.angle_sector = sector; + } + set.dimensions.insert(id, dimension); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + self.tabs[i] + .scene + .attach_dimension_association(dimension, association); + let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i] + .scene + .bump_entities_with_parametric_policy(&changes, &anchors, false); + self.tabs[i].scene.refresh_hidden_dynamic_dimensions(); + self.tabs[i].scene.refresh_dynamic_dimension_scales(true); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + None + } + + pub(super) fn handle_make_parallel(&mut self, result: CmdResult) -> Task { + let i = self.active_tab; + let CmdResult::MakeParallel { + first_line, + first_ends, + first_pick, + second_line, + second_ends, + } = result + else { + unreachable!("router only routes the matching variant"); + }; + use crate::scene::parametric_constraints::{ + resolve_point, two_lines_placed_entity, ConstraintKind, + }; + let scope = self.tabs[i].current_parametric_scope(); + let refs = [first_line, second_line]; + if let Err(message) = + self.tabs[i] + .scene + .validate_parametric_constraint(ConstraintKind::Parallel, &refs, None) + { + return self.apply_cmd_result(CmdResult::ReportError(message.to_string())); + } + // The reference's placement of the second line, written + // before the Parallel constraint holds it there. Built out of + // line and boxed: this arm's locals would otherwise sit in + // `apply_cmd_result`'s frame, which is on every click's stack. + let placed_entity = two_lines_placed_entity( + &self.tabs[i].scene.document, + first_ends, + first_pick, + second_line, + second_ends, + ); + let already = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .is_some_and(|set| { + set.constraints.iter().any(|c| { + c.enabled + && c.kind == ConstraintKind::Parallel + && c.refs.contains(&first_line) + && c.refs.contains(&second_line) + }) + }); + if !already || placed_entity.is_some() { + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new( + scope, + ) + }); + let pending = self.begin_undo(i, "Parallel constraint", 1, true); + if let Some(entity) = placed_entity { + self.tabs[i].scene.replace_entity_recorded(entity); + } + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + if !already { + let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); + let id = set.add(ConstraintKind::Parallel, refs.to_vec(), None); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + } + // Both lines' ends hold the placement; the solve only + // settles whatever else is tied to them. + let mut pins = first_ends.to_vec(); + pins.extend(second_ends); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &[(second_line.entity, crate::scene::ChangeKind::Modified)], + &pins, + self.constraint_solve_mode, + ); + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + let ends = { + let document = &self.tabs[i].scene.document; + document.get_entity(second_line.entity).and_then(|entity| { + let world = |reference: crate::scene::parametric_constraints::ParametricRef| { + let point = resolve_point(entity, reference.marker?)?; + Some((reference, glam::DVec3::new(point.x, point.y, point.z))) + }; + Some([world(second_ends[0])?, world(second_ends[1])?]) + }) + }; + let Some(ends) = ends else { + return self.apply_cmd_result(CmdResult::ReportError( + crate::modules::parametric::DimConstraintCommand::NO_OBJECT.to_string(), + )); + }; + let result = self.tabs[i] + .active_cmd + .as_mut() + .map(|command| command.accept_parallel_line(ends)); + match result { + Some(result) => self.apply_cmd_result(result), + None => Task::none(), + } + } + + pub(super) fn handle_add_fixed_constraint( + &mut self, + pick: crate::command::CoincidentPick, + ) -> Task { + let i = self.active_tab; + use crate::modules::parametric::FixConstraintCommand; + use crate::scene::parametric_constraints::{ + nearest_parametric_point, nearest_parametric_point_on_entity, + parametric_curve_ref_for_pick, ConstraintKind, + }; + + let scope = self.tabs[i].current_parametric_scope(); + let document = &self.tabs[i].scene.document; + let world = + acadrust::types::Vector3::new(pick.point.x, pick.point.y, pick.point.z); + let resolved = match (pick.whole_curve, pick.handle) { + (true, Some(handle)) => { + parametric_curve_ref_for_pick(document, scope, handle, world) + .ok_or(FixConstraintCommand::INVALID_OBJECT) + } + (true, None) => Err(FixConstraintCommand::NO_OBJECT), + (false, Some(handle)) => { + nearest_parametric_point_on_entity(document, scope, handle, world) + .ok_or(FixConstraintCommand::INVALID_OBJECT) + } + (false, None) => nearest_parametric_point(document, scope, world, None) + .ok_or(FixConstraintCommand::NO_POINT), + }; + // An off-plane or 3D curve is "not a valid object" to the + // reference, not a solver limitation. + let resolved = resolved.and_then(|reference| { + let supported = !pick.whole_curve + || self.tabs[i] + .scene + .validate_parametric_constraint( + ConstraintKind::Fixed, + &[reference], + None, + ) + .is_ok(); + supported + .then_some(reference) + .ok_or(FixConstraintCommand::INVALID_OBJECT) + }); + match resolved { + Ok(reference) => self.apply_cmd_result(CmdResult::AddParametricConstraint { + kind: ConstraintKind::Fixed, + refs: vec![reference], + driving_param: None, + label: "Fixed constraint", + }), + // A miss re-prompts: `ReportError` keeps the command. + Err(message) => { + self.apply_cmd_result(CmdResult::ReportError(message.to_string())) + } + } + } + + pub(super) fn handle_check_horizontal_point( + &mut self, + kind: crate::scene::parametric_constraints::ConstraintKind, + pick: crate::command::CoincidentPick, + ) -> Task { + let i = self.active_tab; + use crate::modules::parametric::HorizontalConstraintCommand; + use crate::scene::parametric_constraints::nearest_parametric_point; + + // The reference rejects a missed first point right away and + // asks for it again; a hit moves on to the second point. + let scope = self.tabs[i].current_parametric_scope(); + let found = nearest_parametric_point( + &self.tabs[i].scene.document, + scope, + acadrust::types::Vector3::new(pick.point.x, pick.point.y, pick.point.z), + None, + ) + .is_some(); + if !found { + self.command_line + .push_error("No valid constraint point found."); + } + let command = HorizontalConstraintCommand::resume(kind, found.then_some(pick)); + self.command_line + .push_info(&crate::command::CadCommand::prompt(&command)); + self.tabs[i].active_cmd = Some(Box::new(command)); + self.tabs[i].snap_result = None; + Task::none() + } + + pub(super) fn handle_add_horizontal_constraint(&mut self, result: CmdResult) -> Option> { + let i = self.active_tab; + let CmdResult::AddHorizontalConstraint { + kind, + selection, + direction, + label, + } = result + else { + unreachable!("router only routes the matching variant"); + }; + use crate::command::{EntityTransform, HorizontalConstraintSelection}; + use crate::modules::parametric::HorizontalConstraintCommand; + use crate::scene::parametric_constraints::{ + nearest_parametric_point, nearest_parametric_point_on_entity, resolve_point, + ConstraintKind, DirectionalAxis, ParametricRef, + }; + + let vertical = kind == ConstraintKind::Vertical; + let axis = if vertical { "Vertical" } else { "Horizontal" }; + let scope = self.tabs[i].current_parametric_scope(); + let to_world = |point: glam::DVec3| { + acadrust::types::Vector3::new(point.x, point.y, point.z) + }; + let (refs, initial_fixed) = match selection { + HorizontalConstraintSelection::Reference(reference) => { + // The reference turns the object about its first + // vertex: a line's start, the picked polyline + // segment's first vertex, a text's insertion point, + // an ellipse's center. + let initial_fixed = match reference.directional_axis() { + Some(DirectionalAxis::EllipseMajor | DirectionalAxis::EllipseMinor) => { + vec![ParametricRef::center(reference.entity)] + } + Some(DirectionalAxis::TextBaseline) => { + vec![ParametricRef::point(reference.entity, 0)] + } + None => vec![ParametricRef::point( + reference.entity, + reference.segment_index().map_or(0, |index| index as i32), + )], + }; + (vec![reference], initial_fixed) + } + HorizontalConstraintSelection::Points(first, second) => { + let resolve = |pick: crate::command::CoincidentPick| { + if let Some(handle) = pick.handle { + nearest_parametric_point_on_entity( + &self.tabs[i].scene.document, + scope, + handle, + to_world(pick.point), + ) + } else { + nearest_parametric_point( + &self.tabs[i].scene.document, + scope, + to_world(pick.point), + None, + ) + } + }; + // A miss asks for that point again, keeping a good + // first pick; the same point twice asks for another + // second point — the reference's own re-prompts. + let (first_ref, second_ref) = match (resolve(first), resolve(second)) { + (Some(first_ref), Some(second_ref)) if first_ref != second_ref => { + (first_ref, second_ref) + } + (first_ref, second_ref) => { + let (message, keep_first) = if first_ref.is_none() { + ("No valid constraint point found.", None) + } else if second_ref.is_none() { + ("No valid constraint point found.", Some(first)) + } else { + ( + "The object or point is already selected. Select a different object or constraint point.", + Some(first), + ) + }; + let command = HorizontalConstraintCommand::resume(kind, keep_first); + self.command_line.push_error(message); + self.command_line + .push_info(&crate::command::CadCommand::prompt(&command)); + self.tabs[i].active_cmd = Some(Box::new(command)); + self.tabs[i].snap_result = None; + return Some(Task::none()); + } + }; + (vec![first_ref, second_ref], vec![first_ref]) + } + }; + let axis_length = direction.x.hypot(direction.y); + if axis_length <= 1.0e-12 { + self.command_line.push_error(&format!( + "{axis}: the current UCS {} axis is not supported.", + if vertical { "Y" } else { "X" } + )); + return Some(Task::none()); + } + let direction = acadrust::types::Vector3::new( + direction.x / axis_length, + direction.y / axis_length, + 0.0, + ); + if let Err(message) = + self.tabs[i].scene.validate_parametric_constraint(kind, &refs, None) + { + self.command_line.push_error(message); + return Some(Task::none()); + } + if self + .tabs[i] + .scene + .parametric_constraint_set(scope) + .is_some_and(|set| set.contains_axis_constraint(kind, &refs, direction)) + { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line + .push_error("The constraint already exists on the selected objects."); + return Some(Task::none()); + } + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + let mut touched = Vec::new(); + for reference in &refs { + if !touched.contains(&reference.entity) { + touched.push(reference.entity); + } + } + let pending = self.begin_undo(i, label, touched.len(), true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + // Two points on different objects: the reference slides the + // second object rigidly onto the axis through the first + // point instead of re-solving its shape, so move it first + // and let the constraint then hold what already fits. + if let [first_ref, second_ref] = refs.as_slice() { + if first_ref.entity != second_ref.entity { + let position = |reference: &ParametricRef| { + self.tabs[i] + .scene + .document + .get_entity(reference.entity) + .zip(reference.marker) + .and_then(|(entity, marker)| resolve_point(entity, marker)) + }; + if let (Some(first_point), Some(second_point)) = + (position(first_ref), position(second_ref)) + { + let normal = (-direction.y, direction.x); + let offset = (second_point.x - first_point.x) * normal.0 + + (second_point.y - first_point.y) * normal.1; + if offset.abs() > 1.0e-9 { + self.tabs[i].scene.transform_entities( + &[second_ref.entity], + &EntityTransform::Translate(glam::DVec3::new( + -offset * normal.0, + -offset * normal.1, + 0.0, + )), + ); + } + } + } + } + // The solver cannot start from an axis lying exactly across + // the datum (its equations are singular there), so turn the + // object onto the axis about its anchor first — the + // reference turns it about that anchor too — and let the + // constraint hold what already fits. + if let Some((handle, anchor, end, vertex)) = + crate::scene::parametric_constraints::axis_alignment_target( + &self.tabs[i].scene.document, + &refs, + ) + { + let axis = (end.x - anchor.x, end.y - anchor.y); + let length = axis.0.hypot(axis.1); + let target = if axis.0 * direction.x + axis.1 * direction.y >= 0.0 { + (direction.x, direction.y) + } else { + (-direction.x, -direction.y) + }; + let angle = (axis.0 * target.1 - axis.1 * target.0) + .atan2(axis.0 * target.0 + axis.1 * target.1); + if length > 1.0e-9 && angle.abs() > 1.0e-9 { + match vertex { + Some(index) => { + let moved = + self.tabs[i].scene.document.get_entity(handle).cloned(); + if let Some(mut entity) = moved { + if crate::scene::parametric_constraints::set_polyline_vertex( + &mut entity, + index, + anchor.x + length * target.0, + anchor.y + length * target.1, + ) { + self.tabs[i].scene.update_entity(entity); + } + } + } + None => self.tabs[i].scene.transform_entities( + &[handle], + &EntityTransform::Rotate { + center: glam::DVec3::new(anchor.x, anchor.y, anchor.z), + axis: glam::DVec3::Z, + angle_rad: angle, + }, + ), + } + } + } + let id = self.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .add_axis_constraint(kind, refs, direction); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + let changes = touched + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect::>(); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &initial_fixed, + self.constraint_solve_mode, + ); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line + .push_output(&format!("{axis} constraint applied.")); + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + None + } + + pub(super) fn handle_add_symmetric_constraint( + &mut self, + selection: crate::command::SymmetricConstraintSelection, + axis: crate::scene::parametric_constraints::ParametricRef, + label: &'static str, + ) -> Option> { + let i = self.active_tab; + use crate::command::SymmetricConstraintSelection; + use crate::scene::parametric_constraints::ConstraintKind; + + let scope = self.tabs[i].current_parametric_scope(); + let to_world = |point: glam::DVec3| { + acadrust::types::Vector3::new(point.x, point.y, point.z) + }; + let resolve_point = |pick: crate::command::CoincidentPick| { + if let Some(handle) = pick.handle { + crate::scene::parametric_constraints::nearest_parametric_point_on_entity( + &self.tabs[i].scene.document, + scope, + handle, + to_world(pick.point), + ) + } else { + crate::scene::parametric_constraints::nearest_parametric_point( + &self.tabs[i].scene.document, + scope, + to_world(pick.point), + None, + ) + } + }; + let (first, second) = match selection { + SymmetricConstraintSelection::Objects(first, second) => (first, second), + SymmetricConstraintSelection::Points(first, second) => { + let (Some(first), Some(second)) = + (resolve_point(first), resolve_point(second)) + else { + self.command_line.push_error( + "Symmetric: select supported endpoints, centers, midpoints, or vertices.", + ); + return Some(Task::none()); + }; + (first, second) + } + }; + if first == second + || axis.entity == first.entity + || axis.entity == second.entity + { + self.command_line + .push_error("Symmetric: select two different references and a separate line axis."); + return Some(Task::none()); + } + let refs = vec![first, second, axis]; + if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( + ConstraintKind::Symmetric, + &refs, + None, + ) { + self.command_line.push_error(message); + return Some(Task::none()); + } + let duplicate = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .is_some_and(|set| { + set.constraints.iter().any(|constraint| { + constraint.enabled + && constraint.kind == ConstraintKind::Symmetric + && matches!(constraint.refs.as_slice(), [a, b, m] + if *m == axis + && ((*a == first && *b == second) + || (*a == second && *b == first))) + }) + }); + if duplicate { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line + .push_error("The constraint already exists on the selected objects."); + return Some(Task::none()); + } + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + let mut touched = Vec::new(); + for reference in &refs { + if !touched.contains(&reference.entity) { + touched.push(reference.entity); + } + } + let pending = self.begin_undo(i, label, touched.len(), true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + let id = self.tabs[i].scene.parametric_constraint_set_mut(scope).add( + ConstraintKind::Symmetric, + refs, + None, + ); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + let changes = touched + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect::>(); + self.tabs[i].scene.bump_entities_with_initial_parametric_policy( + &changes, + &[first, axis], + false, + ); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line + .push_output("Symmetric constraint applied."); + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + None + } + + pub(super) fn handle_add_perpendicular_constraint(&mut self, result: CmdResult) -> Option> { + let i = self.active_tab; + let CmdResult::AddPerpendicularConstraint { + first, + second, + first_fixed, + second_start, + label, + } = result + else { + unreachable!("router only routes the matching variant"); + }; + use crate::scene::parametric_constraints::ConstraintKind; + + let refs = vec![first, second]; + if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( + ConstraintKind::Perpendicular, + &refs, + None, + ) { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line.push_error(message); + return Some(Task::none()); + } + let scope = self.tabs[i].current_parametric_scope(); + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + let touched = if first.entity == second.entity { + vec![first.entity] + } else { + vec![first.entity, second.entity] + }; + let pending = self.begin_undo(i, label, touched.len(), true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + let id = self.tabs[i].scene.parametric_constraint_set_mut(scope).add( + ConstraintKind::Perpendicular, + refs, + None, + ); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + let changes = touched + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect::>(); + self.tabs[i].scene.bump_entities_with_initial_parametric_policy( + &changes, + &[first_fixed, second_start], + true, + ); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line.push_output("Constraint applied."); + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + None + } + + pub(super) fn handle_add_tangent_constraint( + &mut self, + first: crate::scene::parametric_constraints::ParametricRef, + second: crate::scene::parametric_constraints::ParametricRef, + label: &'static str, + ) -> Option> { + let i = self.active_tab; + use crate::scene::parametric_constraints::ConstraintKind; + + let refs = vec![first, second]; + if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( + ConstraintKind::Tangent, + &refs, + None, + ) { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line.push_error(message); + return Some(Task::none()); + } + let scope = self.tabs[i].current_parametric_scope(); + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + let touched = if first.entity == second.entity { + vec![first.entity] + } else { + vec![first.entity, second.entity] + }; + let pending = self.begin_undo(i, label, touched.len(), true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + let id = self.tabs[i].scene.parametric_constraint_set_mut(scope).add( + ConstraintKind::Tangent, + refs, + None, + ); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + let changes = touched + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect::>(); + self.tabs[i].scene.bump_entities_with_initial_parametric_policy( + &changes, + &[first], + true, + ); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line.push_output("Constraint applied."); + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + None + } + + pub(super) fn handle_add_concentric_constraint( + &mut self, + first: crate::scene::parametric_constraints::ParametricRef, + second: crate::scene::parametric_constraints::ParametricRef, + label: &'static str, + ) -> Option> { + let i = self.active_tab; + use crate::scene::parametric_constraints::ConstraintKind; + + let refs = vec![first, second]; + if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( + ConstraintKind::Concentric, + &refs, + None, + ) { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line.push_error(message); + return Some(Task::none()); + } + let scope = self.tabs[i].current_parametric_scope(); + let duplicate = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .is_some_and(|set| { + set.constraints.iter().any(|constraint| { + constraint.enabled + && constraint.kind == ConstraintKind::Concentric + && matches!(constraint.refs.as_slice(), [a, b] + if (*a == first && *b == second) + || (*a == second && *b == first)) + }) + }); + if duplicate { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line + .push_output("The Concentric constraint already exists."); + return Some(Task::none()); + } + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + let touched = if first.entity == second.entity { + vec![first.entity] + } else { + vec![first.entity, second.entity] + }; + let pending = self.begin_undo(i, label, touched.len(), true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + let id = self.tabs[i].scene.parametric_constraint_set_mut(scope).add( + ConstraintKind::Concentric, + refs, + None, + ); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + let changes = touched + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect::>(); + self.tabs[i].scene.bump_entities_with_initial_parametric_policy( + &changes, + &[first], + true, + ); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line.push_output("Constraint applied."); + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + None + } + + pub(super) fn handle_add_coincident_constraint(&mut self, result: CmdResult) -> Option> { + let i = self.active_tab; + let CmdResult::AddCoincidentConstraint { + first, + second, + multiple, + label, + } = result + else { + unreachable!("router only routes the matching variant"); + }; + let scope = self.tabs[i].current_parametric_scope(); + let to_world = |p: glam::DVec3| acadrust::types::Vector3::new(p.x, p.y, p.z); + let resolve = |pick: crate::command::CoincidentPick, + exclude: Option| { + if pick.whole_curve { + pick.handle.and_then(|handle| { + crate::scene::parametric_constraints::parametric_curve_ref_for_pick( + &self.tabs[i].scene.document, + scope, + handle, + to_world(pick.point), + ) + }) + } else if let Some(handle) = pick.handle { + crate::scene::parametric_constraints::nearest_parametric_point_on_entity( + &self.tabs[i].scene.document, + scope, + handle, + to_world(pick.point), + ) + } else { + crate::scene::parametric_constraints::nearest_parametric_point( + &self.tabs[i].scene.document, + scope, + to_world(pick.point), + exclude, + ) + } + }; + let resolved_first = resolve(first, None); + let resolved_second = resolved_first + .and_then(|reference| resolve(second, Some(reference.entity))); + let (Some(first_ref), Some(second_ref)) = (resolved_first, resolved_second) else { + self.command_line.push_error( + "Coincident: select a supported endpoint, center, midpoint, vertex, or curve.", + ); + return Some(Task::none()); + }; + let (kind, refs) = match (first.whole_curve, second.whole_curve) { + (false, false) if first_ref != second_ref => ( + crate::scene::parametric_constraints::ConstraintKind::Coincident, + vec![first_ref, second_ref], + ), + (true, false) => ( + crate::scene::parametric_constraints::ConstraintKind::PointOnCurve, + vec![second_ref, first_ref], + ), + (false, true) => ( + crate::scene::parametric_constraints::ConstraintKind::PointOnCurve, + vec![first_ref, second_ref], + ), + _ => { + self.command_line + .push_error("Coincident: select one point and one curve, or two points."); + return Some(Task::none()); + } + }; + if let Err(error) = self.tabs[i] + .scene + .validate_parametric_constraint(kind, &refs, None) + { + self.command_line.push_error(error); + return Some(Task::none()); + } + let touched: Vec<_> = refs.iter().map(|reference| reference.entity).collect(); + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + let pending = self.begin_undo(i, label, touched.len(), true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + let id = self.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .add(kind, refs, None); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + let changes: Vec<_> = touched + .iter() + .copied() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &[first_ref], + self.constraint_solve_mode, + ); + self.tabs[i].dirty = true; + self.tabs[i].snap_result = None; + if !multiple { + self.tabs[i].active_cmd = None; + } + self.command_line.push_output("Coincident constraint applied."); + if multiple { + if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|cmd| cmd.prompt()) { + self.command_line.push_info(&prompt); + } + self.command_line.set_step_options( + self.tabs[i] + .active_cmd + .as_ref() + .map(|cmd| cmd.options()) + .unwrap_or_default(), + ); + } + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + None + } + + pub(super) fn handle_add_auto_coincident_constraints(&mut self, handles: Vec) { + let i = self.active_tab; + use crate::scene::parametric_constraints::ConstraintKind; + let scope = self.tabs[i].current_parametric_scope(); + let inferred = self.tabs[i] + .scene + .inferred_coincident_constraints(scope, &handles); + let count = inferred.len(); + if count > 0 { + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new( + scope, + ) + }); + let pending = self.begin_undo(i, "Coincident auto constrain", handles.len(), true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + for refs in inferred { + let id = self.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .add(ConstraintKind::Coincident, refs, None); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + } + let changes: Vec<_> = handles + .iter() + .copied() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].dirty = true; + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line + .push_output(format!("{} coincident constraint(s) applied.", count).as_str()); + } + + pub(super) fn handle_add_point_on_entity_constraint( + &mut self, + result: CmdResult, + ) -> Option> { + let i = self.active_tab; + let CmdResult::AddPointOnEntityConstraint { + point, + target, + kind, + label, + } = result + else { + unreachable!("router only routes the matching variant"); + }; + let scope = self.tabs[i].current_parametric_scope(); + let to_world = |p: glam::DVec3| acadrust::types::Vector3::new(p.x, p.y, p.z); + let resolved = crate::scene::parametric_constraints::nearest_parametric_point( + &self.tabs[i].scene.document, + scope, + to_world(point), + Some(target), + ); + match resolved { + Some(point_ref) => { + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; + // `CenterPoint` addresses its whole-entity side via + // the center marker (same solve as Coincident / + // Concentric — `ConstraintKind::CenterPoint`'s own + // doc comment); `Midpoint`/`PointOnCurve` address it + // as a whole entity. + let target_ref = if kind == ConstraintKind::CenterPoint { + ParametricRef::center(target) + } else { + ParametricRef::whole(target) + }; + return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint { + kind, + refs: vec![point_ref, target_ref], + driving_param: None, + label, + })); + } + None => { + self.command_line.push_error( + "Pick didn't land on a point (endpoint, center, or vertex) — enable an Endpoint/Center object snap and try again.", + ); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + } + } + None + } + + pub(super) fn handle_add_equal_distance_constraint( + &mut self, + points: [glam::DVec3; 4], + label: &'static str, + ) -> Option> { + let i = self.active_tab; + let scope = self.tabs[i].current_parametric_scope(); + let to_world = |p: glam::DVec3| acadrust::types::Vector3::new(p.x, p.y, p.z); + let mut refs = Vec::with_capacity(4); + for p in points { + let Some(r) = crate::scene::parametric_constraints::nearest_parametric_point( + &self.tabs[i].scene.document, + scope, + to_world(p), + None, + ) else { + refs.clear(); + break; + }; + refs.push(r); + } + if refs.len() == 4 { + return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint { + kind: crate::scene::parametric_constraints::ConstraintKind::EqualDistance, + refs, + driving_param: None, + label, + })); + } + self.command_line.push_error( + "Equal Distance: a pick didn't land on a point (endpoint, center, or vertex) — enable an Endpoint/Center object snap and try again.", + ); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + None + } +} diff --git a/src/app/command_driver/dim_edit.rs b/src/app/command_driver/dim_edit.rs new file mode 100644 index 000000000..169e60666 --- /dev/null +++ b/src/app/command_driver/dim_edit.rs @@ -0,0 +1,521 @@ +use super::*; + +impl OpenCADStudio { + pub(super) fn handle_edit_dimension_break( + &mut self, + dimensions: Vec, + operation: crate::command::DimensionBreakOperation, + ) { + let i = self.active_tab; + self.push_undo_snapshot(i, "DIMBREAK"); + let result = apply_dimbreak(&mut self.tabs[i].scene, &dimensions, operation); + if result.changed.is_empty() { + self.command_line.push_info(&result.message); + } else { + for handle in &result.changed { + self.tabs[i].scene.invalidate_dim_block_recorded(*handle); + } + let changes = result + .changed + .iter() + .copied() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect::>(); + self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].dirty = true; + self.command_line.push_output(&result.message); + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + } + + pub(super) fn handle_edit_dimension_jog(&mut self, dimension: Handle, point: Option) { + let i = self.active_tab; + let valid = matches!( + self.tabs[i].scene.document.get_entity(dimension), + Some(acadrust::EntityType::Dimension( + acadrust::entities::Dimension::Linear(_) + | acadrust::entities::Dimension::Aligned(_) + )) + ); + if !valid { + self.command_line.push_error( + crate::t!("DIMJOGLINE: select a linear or aligned dimension.").as_ref(), + ); + } else if !self.reject_locked_edit(i, dimension) { + self.push_undo_snapshot(i, "DIMJOGLINE"); + let values = point.map(|point| { + use acadrust::xdata::XDataValue; + vec![ + XDataValue::Integer16(387), + XDataValue::Integer16(3), + XDataValue::Integer16(389), + XDataValue::Point3D(acadrust::types::Vector3::new( + point.x, point.y, point.z, + )), + ] + }); + crate::scene::view::dispatch::set_entity_xdata( + &mut self.tabs[i].scene.document, + dimension, + "ACAD_DSTYLE_DIMJAG_POSITION", + values, + ); + self.tabs[i].scene.invalidate_dim_block_recorded(dimension); + self.tabs[i] + .scene + .bump_entities(&[(dimension, crate::scene::ChangeKind::Modified)]); + self.tabs[i].dirty = true; + self.command_line.push_output( + if point.is_some() { + crate::t!("DIMJOGLINE: jog added.") + } else { + crate::t!("DIMJOGLINE: jog removed.") + } + .as_ref(), + ); + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + } + + pub(super) fn handle_space_dimensions(&mut self, base: Handle, others: Vec, spacing: Option) { + let i = self.active_tab; + self.push_undo_snapshot(i, "DIMSPACE"); + if apply_dimspace(&mut self.tabs[i].scene, base, &others, spacing) { + self.tabs[i].dirty = true; + self.command_line + .push_output(crate::t!("DIMSPACE Spacing adjusted.").as_ref()); + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + } +} + +struct DimBreakResult { + changed: Vec, + message: String, +} + +fn wire_segments( + models: &[crate::scene::model::wire_model::WireModel], +) -> Vec<(glam::DVec3, glam::DVec3)> { + let mut output = Vec::new(); + for model in models { + for run in model.points.split(|point| !point[0].is_finite()) { + for pair in run.windows(2) { + let first = + glam::DVec3::new(pair[0][0] as f64, pair[0][1] as f64, pair[0][2] as f64); + let second = + glam::DVec3::new(pair[1][0] as f64, pair[1][1] as f64, pair[1][2] as f64); + if first.is_finite() + && second.is_finite() + && first.distance_squared(second) > 1.0e-18 + { + output.push((first, second)); + } + } + } + } + output +} + +fn segment_intersection_xy( + first: (glam::DVec3, glam::DVec3), + second: (glam::DVec3, glam::DVec3), +) -> Option<(glam::DVec3, glam::DVec3)> { + let gap = cadkernel::space::segment_break_gap_xy( + [first.0.to_array(), first.1.to_array()], + [second.0.to_array(), second.1.to_array()], + 0.25, + )?; + Some(( + glam::DVec3::from_array(gap[0]), + glam::DVec3::from_array(gap[1]), + )) +} + +fn break_object_handle(document: &acadrust::CadDocument, dimension: Handle) -> Option { + document.objects.iter().find_map(|(handle, object)| { + let acadrust::objects::ObjectType::DataObject(object) = object else { + return None; + }; + let acadrust::objects::DataObjectData::BreakData(data) = &object.data else { + return None; + }; + (data.dimension_reference == dimension).then_some(*handle) + }) +} + +fn remove_dimension_break_data(document: &mut acadrust::CadDocument, dimension: Handle) -> bool { + let handles: Vec<_> = document + .objects + .iter() + .filter_map(|(handle, object)| match object { + acadrust::objects::ObjectType::DataObject(object) => match &object.data { + acadrust::objects::DataObjectData::BreakData(data) + if data.dimension_reference == dimension => + { + Some(*handle) + } + _ => None, + }, + _ => None, + }) + .collect(); + if handles.is_empty() { + return false; + } + for handle in &handles { + document.objects.remove(handle); + } + if let Some(dictionary_handle) = document.extension_dictionary_handle(dimension) { + if let Some(acadrust::objects::ObjectType::Dictionary(dictionary)) = + document.objects.get_mut(&dictionary_handle) + { + dictionary.entries.retain(|(name, handle)| { + !name.eq_ignore_ascii_case("ACAD_BREAKDATA") && !handles.contains(handle) + }); + dictionary + .hard_owner_entries + .retain(|name| !name.eq_ignore_ascii_case("ACAD_BREAKDATA")); + } + } + true +} + +fn write_dimension_break_data( + document: &mut acadrust::CadDocument, + dimension: Handle, + reserved_reference: Handle, + references: Vec, +) -> bool { + use acadrust::objects::{BreakData, DataObject, DataObjectData, Dictionary, ObjectType}; + + if references.is_empty() { + return remove_dimension_break_data(document, dimension); + } + if let Some(handle) = break_object_handle(document, dimension) { + if let Some(ObjectType::DataObject(object)) = document.objects.get_mut(&handle) { + object.data = DataObjectData::BreakData(BreakData { + version: 0, + dimension_reference: dimension, + reserved_reference, + point_references: references, + }); + return true; + } + } + + let dictionary_handle = match document.extension_dictionary_handle(dimension) { + Some(handle) => handle, + None => { + let handle = document.allocate_handle(); + let mut dictionary = Dictionary::new(); + dictionary.handle = handle; + dictionary.owner = dimension; + dictionary.hard_owner = true; + document + .objects + .insert(handle, ObjectType::Dictionary(dictionary)); + if let Some(entity) = document.get_entity_mut(dimension) { + entity.common_mut().xdictionary_handle = Some(handle); + } + handle + } + }; + + let object_handle = document.allocate_handle(); + let mut object = DataObject::new(DataObjectData::BreakData(BreakData { + version: 0, + dimension_reference: dimension, + reserved_reference, + point_references: references, + })); + object.handle = object_handle; + object.owner = dictionary_handle; + document + .objects + .insert(object_handle, ObjectType::DataObject(object)); + if let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&dictionary_handle) { + dictionary + .entries + .retain(|(name, _)| !name.eq_ignore_ascii_case("ACAD_BREAKDATA")); + dictionary.add_entry("ACAD_BREAKDATA", object_handle); + dictionary.set_entry_hard_owner("ACAD_BREAKDATA", true); + } + true +} + +fn break_reference( + identifier: i32, + reference_type: i32, + first: glam::DVec3, + second: glam::DVec3, +) -> acadrust::objects::BreakPointReference { + acadrust::objects::BreakPointReference { + version: 0, + reserved: 0, + reference_type, + flags: 0, + identifier, + first_point: acadrust::types::Vector3::new(first.x, first.y, first.z), + second_point: acadrust::types::Vector3::new(second.x, second.y, second.z), + trailing_version: 0, + } +} + +fn apply_dimbreak( + scene: &mut crate::scene::Scene, + requested: &[Handle], + operation: crate::command::DimensionBreakOperation, +) -> DimBreakResult { + use crate::command::DimensionBreakOperation; + + let dimensions: Vec<_> = requested + .iter() + .copied() + .filter(|handle| { + matches!( + scene.document.get_entity(*handle), + Some(acadrust::EntityType::Dimension(_)) + ) && scene.locked_layer_name(*handle).is_none() + }) + .collect(); + if dimensions.is_empty() { + return DimBreakResult { + changed: Vec::new(), + message: crate::t!("DIMBREAK: no editable dimensions were selected.").into_owned(), + }; + } + + if matches!(operation, DimensionBreakOperation::Remove) { + let changed: Vec<_> = dimensions + .into_iter() + .filter(|handle| remove_dimension_break_data(&mut scene.document, *handle)) + .collect(); + return DimBreakResult { + message: crate::tf!( + "DIMBREAK: removed breaks from {} dimension(s).", + changed.len() + ) + .into_owned(), + changed, + }; + } + + let crossing_handles: Vec = match operation { + DimensionBreakOperation::Object(handle) => vec![handle], + DimensionBreakOperation::Auto => scene + .document + .entities() + .map(|entity| entity.common().handle) + .filter(|handle| { + !dimensions.contains(handle) && scene.entity_belongs_to_active_space(*handle) + }) + .collect(), + DimensionBreakOperation::Manual(_, _) | DimensionBreakOperation::Remove => Vec::new(), + }; + let crossing_segments: Vec<_> = crossing_handles + .iter() + .flat_map(|handle| wire_segments(&scene.wire_models_for(&[*handle]))) + .collect(); + + let mut pending = Vec::new(); + for dimension in &dimensions { + let existing: Vec<_> = break_object_handle(&scene.document, *dimension) + .and_then(|handle| scene.document.objects.get(&handle)) + .and_then(|object| match object { + acadrust::objects::ObjectType::DataObject(object) => match &object.data { + acadrust::objects::DataObjectData::BreakData(data) => { + Some(data.point_references.clone()) + } + _ => None, + }, + _ => None, + }) + .unwrap_or_default(); + let mut references = if matches!(operation, DimensionBreakOperation::Manual(_, _)) { + existing + } else { + existing + .into_iter() + .filter(|reference| reference.reference_type == 2) + .collect() + }; + match operation { + DimensionBreakOperation::Manual(first, second) => { + references.push(break_reference(references.len() as i32, 2, first, second)); + } + DimensionBreakOperation::Auto | DimensionBreakOperation::Object(_) => { + let dimension_segments = wire_segments(&scene.wire_models_for(&[*dimension])); + let mut intersections = Vec::new(); + for dim_segment in &dimension_segments { + for crossing_segment in &crossing_segments { + if let Some(points) = + segment_intersection_xy(*dim_segment, *crossing_segment) + { + let center = (points.0 + points.1) * 0.5; + if intersections.iter().all( + |(first, second): &(glam::DVec3, glam::DVec3)| { + center.distance_squared((*first + *second) * 0.5) > 1.0e-8 + }, + ) { + intersections.push(points); + } + } + } + } + let start = references.len() as i32; + references.extend( + intersections + .into_iter() + .enumerate() + .map(|(index, points)| { + break_reference(start + index as i32, 1, points.0, points.1) + }), + ); + } + DimensionBreakOperation::Remove => {} + } + pending.push((*dimension, references)); + } + + let reserved = match operation { + DimensionBreakOperation::Object(handle) => handle, + _ => Handle::NULL, + }; + let changed: Vec<_> = pending + .into_iter() + .filter_map(|(dimension, references)| { + write_dimension_break_data(&mut scene.document, dimension, reserved, references) + .then_some(dimension) + }) + .collect(); + DimBreakResult { + message: if changed.is_empty() { + crate::t!("DIMBREAK: no intersections were found.").into_owned() + } else { + crate::tf!("DIMBREAK: updated {} dimension(s).", changed.len()).into_owned() + }, + changed, + } +} + +fn apply_dimspace( + scene: &mut crate::scene::Scene, + base_h: Handle, + others: &[Handle], + requested_spacing: Option, +) -> bool { + use acadrust::entities::Dimension; + let (axis, normal, definition, auto_spacing) = match scene.document.get_entity(base_h) { + Some(acadrust::EntityType::Dimension(dimension @ Dimension::Linear(d))) => { + let spacing = dimension_auto_spacing( + &scene.document, + dimension, + scene.creation_annotation_multiplier(), + ); + ( + [d.rotation.cos(), d.rotation.sin(), 0.0], + [d.base.normal.x, d.base.normal.y, d.base.normal.z], + [ + d.definition_point.x, + d.definition_point.y, + d.definition_point.z, + ], + spacing, + ) + } + Some(acadrust::EntityType::Dimension(dimension @ Dimension::Aligned(d))) => { + let spacing = dimension_auto_spacing( + &scene.document, + dimension, + scene.creation_annotation_multiplier(), + ); + ( + [ + d.second_point.x - d.first_point.x, + d.second_point.y - d.first_point.y, + d.second_point.z - d.first_point.z, + ], + [d.base.normal.x, d.base.normal.y, d.base.normal.z], + [ + d.definition_point.x, + d.definition_point.y, + d.definition_point.z, + ], + spacing, + ) + } + _ => return false, + }; + let Some(frame) = cadkernel::space::dimension_spacing_frame(axis, normal, definition) else { + return false; + }; + + let effective_spacing = requested_spacing.unwrap_or(auto_spacing); + let mut changes = Vec::new(); + for (idx, &h) in others.iter().enumerate() { + let target = frame.coordinate + effective_spacing * (idx + 1) as f64; + let mut changed = false; + if let Some(acadrust::EntityType::Dimension(dim)) = scene.document.get_entity_mut(h) { + let slide = |p: &mut acadrust::types::Vector3| { + let point = + cadkernel::space::move_to_dimension_spacing([p.x, p.y, p.z], frame, target); + *p = acadrust::types::Vector3::new(point[0], point[1], point[2]); + }; + match dim { + Dimension::Linear(d) => { + slide(&mut d.definition_point); + d.base.definition_point = d.definition_point; + changed = true; + } + Dimension::Aligned(d) => { + slide(&mut d.definition_point); + d.base.definition_point = d.definition_point; + changed = true; + } + _ => {} + } + } + if changed { + // The dimension line moved, so its baked *D block is stale — drop it + // so the next save re-bakes it. (#181) + scene.invalidate_dim_block_recorded(h); + changes.push((h, crate::scene::ChangeKind::Modified)); + } + } + if !changes.is_empty() { + scene.bump_entities(&changes); + } + !changes.is_empty() +} + +fn dimension_auto_spacing( + document: &acadrust::CadDocument, + dimension: &acadrust::entities::Dimension, + annotation_multiplier: f64, +) -> f64 { + let style = document + .dim_styles + .iter() + .find(|style| { + style + .name + .eq_ignore_ascii_case(&dimension.base().style_name) + }) + .map(|style| { + crate::entities::dimension::resolved_dimension_style(style, dimension, document) + }); + style + .map(|style| { + let scale = if style.dimscale > 1.0e-9 { + style.dimscale + } else { + annotation_multiplier.max(1.0e-9) + }; + (style.dimdli.abs() * scale).max(1.0e-6) + }) + .unwrap_or(3.75) +} diff --git a/src/app/command_driver/entity_commit.rs b/src/app/command_driver/entity_commit.rs new file mode 100644 index 000000000..7949ca248 --- /dev/null +++ b/src/app/command_driver/entity_commit.rs @@ -0,0 +1,1113 @@ +use super::*; + +impl OpenCADStudio { + pub(super) fn handle_commit_entity( + &mut self, + entity: acadrust::EntityType, + preserve_commit_layer: bool, + ) -> Option> { + let i = self.active_tab; + let source_handle = entity.common().handle; + if !source_handle.is_null() + && self.tabs[i] + .scene + .document + .get_entity(source_handle) + .is_some() + && self.reject_locked_edit(i, source_handle) + { + return Some(Task::none()); + } + // A line/arc drawn by a repeating command advances the ARC_CONT + // continuation anchor, so ending one run and launching another + // keeps continuing from the last segment (mirrors the + // CommitAndExit arm). Non-continuable repeats (point/ray) + // leave the anchor untouched. (#327) + if matches!( + entity, + acadrust::EntityType::Line(_) + | acadrust::EntityType::Arc(_) + | acadrust::EntityType::LwPolyline(_) + | acadrust::EntityType::Polyline2D(_) + ) { + self.update_cont_anchor(&entity); + } + let label = self.history_label_from_active_cmd(i, "ENTITY"); + // Ordinary drawables, viewports and raster images use targeted + // entity/object deltas. Block sentinels and novel layers retain + // the structure snapshot fallback. + let delta_safe = self.delta_add_safe(i, &entity); + let pending = self.begin_undo(i, label, 1, delta_safe); + let is_associative_dimension = matches!( + entity, + acadrust::EntityType::Dimension( + acadrust::entities::Dimension::Linear(_) + | acadrust::entities::Dimension::Aligned(_) + ) + ); + let association_enabled = + self.tabs[i].scene.document.header.dimension_associativity == 2; + let committed = if preserve_commit_layer { + self.commit_entity_handle_preserve_layer(entity) + } else { + self.commit_entity_handle(entity) + }; + if is_associative_dimension && association_enabled { + if let Some(handle) = committed { + let sources = self.infer_dimension_sources_guarded(i, handle); + self.tabs[i] + .scene + .attach_dimension_association(handle, sources); + } + } + if let Some(handle) = committed { + self.apply_continuous_constraints(i, &[handle]); + } + self.tabs[i].dirty = true; + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + None + } + + pub(super) fn handle_commit_entities( + &mut self, + mut entities: Vec, + preserve_commit_style: bool, + preserve_commit_layer: bool, + ) -> Option> { + let i = self.active_tab; + let locked_source = entities.iter().find_map(|entity| { + let handle = entity.common().handle; + (!handle.is_null() + && self.tabs[i].scene.document.get_entity(handle).is_some() + && self.tabs[i].scene.is_layer_locked(handle)) + .then_some(handle) + }); + if let Some(handle) = locked_source { + self.reject_locked_edit(i, handle); + return Some(Task::none()); + } + let label = self.history_label_from_active_cmd(i, "ENTITY"); + let symbol_names = self.tabs[i] + .active_cmd + .as_ref() + .and_then(|command| command.nested_copy_symbol_names()) + .cloned(); + let delta_safe = symbol_names.is_none() + && entities.iter().all(|entity| self.delta_add_safe(i, entity)); + let pending = self.begin_undo(i, label, entities.len(), delta_safe); + if let Some(names) = symbol_names { + let retained = self.tabs[i] + .scene + .document + .localize_nested_copy_symbols(&mut entities, &names); + if retained > 0 { + self.command_line.push_info(&format!("{} copied object(s) retain imported style references whose dependencies cannot be localized.", retained)); + } + self.refresh_layer_panel(); + } + let mut committed = Vec::new(); + for entity in entities { + let handle = if preserve_commit_style { + self.commit_entity_handle_preserve_style(entity) + } else if preserve_commit_layer { + self.commit_entity_handle_preserve_layer(entity) + } else { + self.commit_entity_handle(entity) + }; + committed.extend(handle); + } + self.apply_continuous_constraints(i, &committed); + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + None + } + + pub(super) fn handle_commit_entities_and_exit( + &mut self, + mut entities: Vec, + preserve_commit_style: bool, + preserve_commit_layer: bool, + ) { + let i = self.active_tab; + let label = self.history_label_from_active_cmd(i, "ENTITY"); + let symbol_names = self.tabs[i] + .active_cmd + .as_ref() + .and_then(|command| command.nested_copy_symbol_names()) + .cloned(); + let delta_safe = symbol_names.is_none() + && entities.iter().all(|entity| self.delta_add_safe(i, entity)); + let pending = self.begin_undo(i, label, entities.len(), delta_safe); + if let Some(names) = symbol_names { + let retained = self.tabs[i] + .scene + .document + .localize_nested_copy_symbols(&mut entities, &names); + if retained > 0 { + self.command_line.push_info(&format!("{} copied object(s) retain imported style references whose dependencies cannot be localized.", retained)); + } + self.refresh_layer_panel(); + } + let mut committed = Vec::new(); + for entity in entities { + let handle = if preserve_commit_style { + self.commit_entity_handle_preserve_style(entity) + } else if preserve_commit_layer { + self.commit_entity_handle_preserve_layer(entity) + } else { + self.commit_entity_handle(entity) + }; + committed.extend(handle); + } + self.apply_continuous_constraints(i, &committed); + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + + pub(super) fn handle_commit_and_exit( + &mut self, + entity: acadrust::EntityType, + ) -> Option> { + let i = self.active_tab; + // XATTACH: the definition and its INSERT are created together + // in one undo step. + let xattach_request = self.tabs[i] + .active_cmd + .as_ref() + .filter(|cmd| cmd.name() == "XATTACH") + .and_then(|cmd| cmd.xattach_request()); + if let Some(request) = xattach_request { + self.commit_xref_attach(i, request, entity); + return Some(Task::none()); + } + let insert_block_name = match &entity { + acadrust::EntityType::Insert(ins) => Some(ins.block_name.clone()), + _ => None, + }; + // Record where this draw ended so ARC_CONT can continue from it + // (before `entity` is moved into commit_entity). + self.update_cont_anchor(&entity); + let label = self.history_label_from_active_cmd(i, "ENTITY"); + let delta_safe = self.delta_add_safe(i, &entity); + let pending = self.begin_undo(i, label, 1, delta_safe); + let is_associative_dimension = matches!( + entity, + acadrust::EntityType::Dimension( + acadrust::entities::Dimension::Linear(_) + | acadrust::entities::Dimension::Aligned(_) + ) + ); + let association_enabled = + self.tabs[i].scene.document.header.dimension_associativity == 2; + let committed = self.commit_entity_handle(entity); + if is_associative_dimension && association_enabled { + if let Some(handle) = committed { + let sources = self.infer_dimension_sources_guarded(i, handle); + self.tabs[i] + .scene + .attach_dimension_association(handle, sources); + } + } + if let Some(handle) = committed { + self.apply_continuous_constraints(i, &[handle]); + } + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + if let Some(name) = insert_block_name { + self.record_block_insert(&name); + } + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + None + } + + pub(super) fn handle_commit_dimension(&mut self, result: CmdResult) -> Option> { + let i = self.active_tab; + let CmdResult::CommitDimension { + mut entity, + association, + preserve_base_style, + continue_command, + } = result + else { + unreachable!("router only routes the matching variant"); + }; + let label = self.history_label_from_active_cmd(i, "DIMENSION"); + let association_mode = self.tabs[i].scene.document.header.dimension_associativity; + let single_source_dimension = matches!( + &entity, + acadrust::EntityType::Dimension(acadrust::entities::Dimension::Ordinate(_)) + ); + // A dimension placed on the sheet but measuring model geometry + // through a viewport carries the compensation as a negative + // DIMLFAC override. + if !preserve_base_style { + crate::scene::creation_style::apply_current_creation_styles( + &self.tabs[i].scene.document, + &mut entity, + ); + } + if !self.apply_viewport_dimension_measurement(i, &mut entity) { + return Some(Task::none()); + } + // Projected points cannot use direct paper-space source inference. + let association_allowed = self.dimension_association_allowed(i); + let inherited_dimension = if preserve_base_style { + match &entity { + acadrust::EntityType::Dimension(dimension) => Some(( + dimension.base().common.layer.clone(), + dimension.base().style_name.clone(), + )), + _ => None, + } + } else { + None + }; + let pending = if association_mode == 0 { + let layer = self.tabs[i].active_layer.clone(); + if layer != "0" || entity.as_entity().layer().is_empty() { + entity.as_entity_mut().set_layer(layer); + } + crate::scene::view::dispatch::apply_color( + &mut entity, + self.ribbon.active_color, + ); + crate::scene::view::dispatch::apply_common_prop( + &mut entity, + "linetype", + &self.ribbon.active_linetype.clone(), + ); + crate::scene::view::dispatch::apply_line_weight( + &mut entity, + self.ribbon.active_lineweight, + ); + let celtscale = self.tabs[i] + .scene + .document + .header + .current_entity_linetype_scale; + if (celtscale - 1.0).abs() > 1e-9 && celtscale.abs() > 1e-9 { + entity.common_mut().linetype_scale = celtscale; + } + crate::scene::creation_style::apply_current_creation_styles( + &self.tabs[i].scene.document, + &mut entity, + ); + if let (Some((layer, style_name)), acadrust::EntityType::Dimension(dimension)) = + (inherited_dimension, &mut entity) + { + dimension.base_mut().common.layer = layer; + dimension.base_mut().style_name = style_name; + } + let pieces = crate::modules::draw::modify::explode::explode_entity( + &entity, + &self.tabs[i].scene.document, + ); + let delta_safe = pieces.iter().all(|piece| self.delta_add_safe(i, piece)); + let pending = self.begin_undo(i, label, pieces.len(), delta_safe); + for piece in pieces { + self.tabs[i].scene.add_entity(piece); + } + pending + } else { + let delta_safe = self.delta_add_safe(i, &entity); + let pending = self.begin_undo(i, label, 1, delta_safe); + if let Some(handle) = + self.commit_entity_handle_with_dimension_policy(entity, preserve_base_style) + { + if association_mode == 2 && association_allowed { + let mut changes = vec![(handle, crate::scene::ChangeKind::Modified)]; + match association { + crate::command::DimensionAssociationInput::Infer(source) => { + let sources: Vec<_> = source.map_or_else( + || self.infer_dimension_sources_guarded(i, handle), + |source| { + if single_source_dimension { + vec![Some(source)] + } else { + vec![Some(source), Some(source)] + } + }, + ); + self.tabs[i] + .scene + .attach_dimension_association(handle, sources.clone()); + changes.extend(sources.into_iter().flatten().map(|source| { + (source, crate::scene::ChangeKind::Modified) + })); + } + crate::command::DimensionAssociationInput::Explicit(sources) => { + self.tabs[i].scene.attach_dimension_association_sources( + handle, + sources.clone(), + ); + changes.extend(sources.into_iter().flatten().map(|source| { + (source.handle, crate::scene::ChangeKind::Modified) + })); + } + } + changes.sort_by_key(|(handle, _)| handle.value()); + changes.dedup_by_key(|(handle, _)| handle.value()); + self.tabs[i].scene.bump_entities(&changes); + } else if association_mode == 2 { + // Preserve the acquired viewport and source paths. + self.attach_viewport_dimension_association(i, handle); + } + } + pending + }; + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].snap_result = None; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + if continue_command { + if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|cmd| cmd.prompt()) { + self.command_line.push_info(&prompt); + } + } else { + self.tabs[i].active_cmd = None; + self.restore_pre_cmd_tangent(); + } + None + } + + pub(super) fn handle_commit_dimensions_and_exit( + &mut self, + dimensions: Vec<( + acadrust::EntityType, + crate::command::DimensionAssociationInput, + )>, + ) { + let i = self.active_tab; + let association_mode = self.tabs[i].scene.document.header.dimension_associativity; + let mut made = 0; + let pending = if association_mode == 0 { + let mut pieces = Vec::new(); + for (mut entity, _) in dimensions { + let layer = self.tabs[i].active_layer.clone(); + if layer != "0" || entity.as_entity().layer().is_empty() { + entity.as_entity_mut().set_layer(layer); + } + crate::scene::view::dispatch::apply_color( + &mut entity, + self.ribbon.active_color, + ); + crate::scene::view::dispatch::apply_common_prop( + &mut entity, + "linetype", + &self.ribbon.active_linetype.clone(), + ); + crate::scene::view::dispatch::apply_line_weight( + &mut entity, + self.ribbon.active_lineweight, + ); + let celtscale = self.tabs[i] + .scene + .document + .header + .current_entity_linetype_scale; + if (celtscale - 1.0).abs() > 1e-9 && celtscale.abs() > 1e-9 { + entity.common_mut().linetype_scale = celtscale; + } + crate::scene::creation_style::apply_current_creation_styles( + &self.tabs[i].scene.document, + &mut entity, + ); + let exploded = crate::modules::draw::modify::explode::explode_entity( + &entity, + &self.tabs[i].scene.document, + ); + if !exploded.is_empty() { + made += 1; + } + pieces.extend(exploded); + } + let delta_safe = pieces.iter().all(|piece| self.delta_add_safe(i, piece)); + let pending = self.begin_undo(i, "QDIM", pieces.len(), delta_safe); + for piece in pieces { + self.tabs[i].scene.add_entity(piece); + } + pending + } else { + let delta_safe = dimensions + .iter() + .all(|(entity, _)| self.delta_add_safe(i, entity)); + let pending = self.begin_undo(i, "QDIM", dimensions.len(), delta_safe); + for (entity, association) in dimensions { + let Some(handle) = self.commit_entity_handle(entity) else { + continue; + }; + made += 1; + if association_mode != 2 { + continue; + } + let sources = match association { + crate::command::DimensionAssociationInput::Infer(source) => { + source.map_or_else( + || { + self.infer_dimension_sources_guarded(i, handle) + .into_iter() + .map(|source| { + source.map( + crate::command::DimensionAssociationSource::inferred, + ) + }) + .collect() + }, + |source| { + vec![Some( + crate::command::DimensionAssociationSource::inferred( + source, + ), + )] + }, + ) + } + crate::command::DimensionAssociationInput::Explicit(sources) => { + sources + } + }; + self.tabs[i] + .scene + .attach_dimension_association_sources(handle, sources.clone()); + let mut changes = vec![(handle, crate::scene::ChangeKind::Modified)]; + changes.extend( + sources + .into_iter() + .flatten() + .map(|source| (source.handle, crate::scene::ChangeKind::Modified)), + ); + changes.sort_by_key(|(handle, _)| handle.value()); + changes.dedup_by_key(|(handle, _)| handle.value()); + self.tabs[i].scene.bump_entities(&changes); + } + pending + }; + if made > 0 { + self.tabs[i].dirty = true; + } + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + self.command_line + .push_output(crate::tf!("QDIM {made} dimensions created.").as_ref()); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + + pub(super) fn handle_commit_solid(&mut self, result: CmdResult) { + let i = self.active_tab; + let CmdResult::CommitSolid { + entity, + solid, + history, + erase_source, + } = result + else { + unreachable!("router only routes the matching variant"); + }; + let label = self.history_label_from_active_cmd(i, "SOLID"); + let erase_source = erase_source.filter(|handle| { + self.delete_objects != 0 && !self.tabs[i].scene.is_layer_locked(*handle) + }); + let pending = + self.begin_undo(i, label, 1 + usize::from(erase_source.is_some()), true); + let handle = self.add_solid_model(entity, *solid, history); + if !handle.is_null() { + if let Some(source) = erase_source { + self.tabs[i].scene.erase_entities(&[source]); + } + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + } + + pub(super) fn handle_commit_and_edit_text( + &mut self, + entity: acadrust::EntityType, + ) -> Option> { + let i = self.active_tab; + let annotative_mleader = matches!( + &entity, + acadrust::EntityType::MultiLeader(ml) if ml.enable_annotation_scale + ); + let label = self.history_label_from_active_cmd(i, "ENTITY"); + let delta_safe = self.delta_add_safe(i, &entity) && !annotative_mleader; + let pending = self.begin_undo(i, label, 1, delta_safe); + let handle = self.commit_entity_handle(entity); + + if annotative_mleader { + let scale_handle = self.tabs[i].scene.creation_annotation_scale_handle(); + if let (Some(handle), Some(scale_handle)) = (handle, scale_handle) { + crate::scene::annotative::create_annotation_context( + &mut self.tabs[i].scene.document, + handle, + scale_handle, + ); + self.tabs[i] + .scene + .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); + } + } + + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + self.ribbon.deactivate_tool(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + if let Some(h) = handle { + return Some(self.begin_text_edit(h)); + } + None + } + + pub(super) fn handle_commit_many_and_edit_text( + &mut self, + entities: Vec, + edit_index: usize, + open_editor: bool, + ) -> Option> { + let i = self.active_tab; + let label = self.history_label_from_active_cmd(i, "ENTITY"); + let delta_safe = entities.iter().all(|entity| self.delta_add_safe(i, entity)); + let pending = self.begin_undo(i, label, entities.len(), delta_safe); + let mut edit_handle = None; + let mut leader_handle = None; + for (idx, entity) in entities.into_iter().enumerate() { + let is_leader = matches!(entity, acadrust::EntityType::Leader(_)); + let h = self.commit_entity_handle(entity); + if idx == edit_index { + edit_handle = h; + } + if is_leader { + leader_handle = h; + } + } + // Link the leader to its annotation so the pair edits as a unit + // (double-click on the leader resolves to the text entity). + if let (Some(lh), Some(ah)) = (leader_handle, edit_handle) { + let linked = if let Some(acadrust::EntityType::Leader(l)) = + self.tabs[i].scene.document.get_entity_mut(lh) + { + l.annotation_handle = ah; + true + } else { + false + }; + + if linked { + // The LEADER may already have received its annotation context while + // annotation_handle was still NULL. Refresh it now that the MTEXT link + // is known so the context represents the finished leader. + self.tabs[i].scene.sync_displayed_annotation_context(lh); + + self.tabs[i] + .scene + .bump_entities(&[(lh, crate::scene::ChangeKind::Modified)]); + } + } + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + self.ribbon.deactivate_tool(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + if open_editor { + if let Some(h) = edit_handle { + return Some(self.begin_text_edit(h)); + } + } + None + } + + pub(super) fn handle_create_block( + &mut self, + mut handles: Vec, + name: String, + base: glam::DVec3, + ) -> Option> { + let i = self.active_tab; + handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + if handles.is_empty() { + self.command_line + .push_info(crate::t!("No editable objects selected.").as_ref()); + return Some(Task::none()); + } + self.push_undo_snapshot(i, "BLOCK"); + let ucs = self.tabs[i].ucs_xform(); + let world_to_block = ucs.to_ucs_transform_at(base); + let block_to_world = ucs.to_wcs_transform_at(base); + match self.tabs[i].scene.create_block_from_entities( + &handles, + &name, + &world_to_block, + &block_to_world, + ) { + Ok(insert_handle) => { + self.tabs[i].dirty = true; + self.tabs[i].scene.deselect_all(); + if !insert_handle.is_null() { + self.tabs[i].scene.select_entity(insert_handle, false); + } + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line + .push_output(crate::tf!("Block \"{name}\" created.").as_ref()); + self.refresh_properties(); + } + Err(err) => { + self.discard_last_undo_entry(i); + self.command_line.push_error(&err); + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + } + } + None + } + + pub(super) fn handle_create_block_with_options( + &mut self, + mut options: Box, + ) -> Option> { + let i = self.active_tab; + options.handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + if options.handles.is_empty() { + self.command_line + .push_info(crate::t!("No editable objects selected.").as_ref()); + return Some(Task::none()); + } + self.push_undo_snapshot(i, "BLOCK"); + let name = options.name.clone(); + match self.tabs[i].scene.create_block_with_options(*options) { + Ok(insert_handle) => { + self.tabs[i].dirty = true; + self.tabs[i].scene.deselect_all(); + if !insert_handle.is_null() { + self.tabs[i].scene.select_entity(insert_handle, false); + } + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line + .push_output(crate::tf!("Block \"{name}\" created.").as_ref()); + self.refresh_properties(); + self.refresh_block_palette(); + } + Err(err) => { + self.discard_last_undo_entry(i); + self.command_line.push_error(&err); + } + } + None + } + + pub(super) fn handle_commit_hatch(&mut self, hatch: crate::scene::HatchModel) { + let i = self.active_tab; + let label = self.history_label_from_active_cmd(i, "HATCH"); + let pending = self.begin_undo(i, label, 1, true); + let layer = self.tabs[i].active_layer.clone(); + let new_handle = self.tabs[i].scene.add_hatch(hatch, Some(&layer), None); + if !new_handle.is_null() { + self.tabs[i].scene.select_entity(new_handle, true); + } + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + + pub(super) fn handle_commit_styled_hatch( + &mut self, + hatch: crate::scene::HatchModel, + color: acadrust::types::Color, + transparency: acadrust::types::Transparency, + ) { + let i = self.active_tab; + let label = self.history_label_from_active_cmd(i, "HATCH"); + let pending = self.begin_undo(i, label, 1, true); + let layer = self.tabs[i].active_layer.clone(); + let new_handle = + self.tabs[i] + .scene + .add_hatch(hatch, Some(&layer), Some((color, transparency))); + if !new_handle.is_null() { + self.tabs[i].scene.select_entity(new_handle, true); + } + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + + pub(super) fn handle_commit_hatch_with_boundaries( + &mut self, + mut hatch: crate::scene::HatchModel, + boundaries: Vec, + entity_style: Option<(acadrust::types::Color, acadrust::types::Transparency)>, + ) { + let i = self.active_tab; + let label = self.history_label_from_active_cmd(i, "HATCH"); + let pending = self.begin_undo(i, label, boundaries.len() + 1, true); + let mut sources = Vec::with_capacity(boundaries.len()); + for boundary in boundaries { + let handles = self + .commit_entity_handle(boundary) + .into_iter() + .collect::>(); + sources.push(handles); + } + if let Some(paths) = hatch.boundary_paths.as_mut() { + for (path, handles) in std::sync::Arc::make_mut(paths).iter_mut().zip(&sources) + { + path.boundary_handles = handles.clone(); + path.flags.set_external(!handles.is_empty()); + } + } + hatch.boundary_sources = Some(std::sync::Arc::new(sources)); + let layer = self.tabs[i].active_layer.clone(); + let new_handle = self.tabs[i] + .scene + .add_hatch(hatch, Some(&layer), entity_style); + if !new_handle.is_null() { + self.tabs[i].scene.select_entity(new_handle, true); + } + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + + pub(super) fn handle_commit_hatches( + &mut self, + hatches: Vec, + entity_style: Option<(acadrust::types::Color, acadrust::types::Transparency)>, + ) { + let i = self.active_tab; + let label = self.history_label_from_active_cmd(i, "HATCH"); + let pending = self.begin_undo(i, label, hatches.len(), true); + let layer = self.tabs[i].active_layer.clone(); + for hatch in hatches { + let new_handle = + self.tabs[i] + .scene + .add_hatch(hatch, Some(&layer), entity_style.clone()); + if !new_handle.is_null() { + self.tabs[i].scene.select_entity(new_handle, true); + } + } + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + + pub(super) fn handle_update_entity_and_finish( + &mut self, + handle: Handle, + entity: acadrust::EntityType, + ) -> Option> { + let i = self.active_tab; + if self.reject_locked_edit(i, handle) { + self.tabs[i].active_cmd = None; + return Some(Task::none()); + } + let label = self.history_label_from_active_cmd(i, "EDIT"); + self.push_undo_snapshot(i, label); + let is_dimension = matches!(entity, acadrust::EntityType::Dimension(_)); + if let Some(current) = self.tabs[i].scene.document.get_entity_mut(handle) { + *current = entity; + if is_dimension { + self.tabs[i].scene.invalidate_dim_block_recorded(handle); + } + self.tabs[i] + .scene + .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); + self.tabs[i].dirty = true; + } else { + self.command_line.push_error( + crate::t!("The selected object is no longer available.").as_ref(), + ); + } + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.refresh_properties(); + None + } + + pub(super) fn handle_reassociate_center_mark( + &mut self, + target: Handle, + source: Handle, + point: glam::DVec3, + ) -> Option> { + let i = self.active_tab; + if self.reject_locked_edit(i, target) { + return Some(Task::none()); + } + self.push_undo_snapshot(i, "CENTERREASSOCIATE"); + if self.tabs[i] + .scene + .reassociate_center_mark(target, source, point) + { + self.tabs[i].dirty = true; + self.command_line.push_output( + crate::t!("CENTERREASSOCIATE: center mark associated.").as_ref(), + ); + } else { + self.command_line.push_error( + crate::t!("CENTERREASSOCIATE: the selected source is not circular.") + .as_ref(), + ); + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.refresh_properties(); + None + } + + pub(super) fn handle_attreq_needed(&mut self, block_name: String) -> Option> { + let i = self.active_tab; + // Collect the full AttributeDefinitions owned by this block + // record so each created attribute keeps its geometry (#255). + let attdefs: Vec = { + let doc = &self.tabs[i].scene.document; + if let Some(br) = doc.block_records.get(&block_name) { + br.entity_handles + .iter() + .filter_map(|&h| { + if let Some(acadrust::EntityType::AttributeDefinition(ad)) = + doc.get_entity(h) + { + Some(ad.clone()) + } else { + None + } + }) + .collect() + } else { + vec![] + } + }; + + if attdefs.is_empty() { + // No attribute definitions — commit the INSERT directly. + let entity = self.tabs[i] + .active_cmd + .as_mut() + .and_then(|c| c.attreq_take_insert()); + if let Some(entity) = entity { + let insert_name = match &entity { + acadrust::EntityType::Insert(ins) => Some(ins.block_name.clone()), + _ => None, + }; + let label = self.history_label_from_active_cmd(i, "INSERT"); + let delta_safe = self.delta_add_safe(i, &entity); + let pending = self.begin_undo(i, label, 1, delta_safe); + self.commit_entity(entity); + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + if let Some(n) = insert_name { + self.record_block_insert(&n); + } + self.restore_pre_cmd_tangent(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + } else { + let completed = self.tabs[i] + .active_cmd + .as_mut() + .and_then(|cmd| cmd.attreq_set_attdefs(attdefs)); + if let Some(entity) = completed { + return Some(self.apply_cmd_result(CmdResult::CommitAndExit(entity))); + } + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + } + None + } + + pub(super) fn handle_commit_live_entity(&mut self, entity: acadrust::EntityType) { + let i = self.active_tab; + let label = self.history_label_from_active_cmd(i, "ENTITY"); + let delta_safe = self.delta_add_safe(i, &entity); + let pending = self.begin_undo(i, label, 1, delta_safe); + let handle = self.commit_entity_handle(entity); + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + if let Some(h) = handle { + // Keep the live document Arc unique while later vertices + // replace the geometry in place. The final Arc is captured + // by UpdateLiveEntity when the command completes. + self.defer_live_entity_history_after(i, h); + if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + cmd.set_live_handle(h); + } + } + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + } + + pub(super) fn handle_update_live_entity( + &mut self, + handle: Handle, + entity: acadrust::EntityType, + finish: bool, + ) { + let i = self.active_tab; + let tracks_draw_anchor = matches!( + &entity, + acadrust::EntityType::Line(_) + | acadrust::EntityType::Arc(_) + | acadrust::EntityType::LwPolyline(_) + | acadrust::EntityType::Polyline(_) + | acadrust::EntityType::Polyline2D(_) + | acadrust::EntityType::Polyline3D(_) + ); + // Replace the live entity's geometry in place, preserving its + // handle and layer (the fresh entity from the command carries + // defaults — a NULL handle would desync it from the document + // map key and drop it from rendering / hit-test). No undo + // snapshot — the create already pushed one, so the whole object + // reverts as a unit. + if let Some(old) = self.tabs[i].scene.document.get_entity_mut(handle) { + // The initial live commit assigns the handle, owning block, + // current layer and common display properties. Geometry + // refreshes must retain all of that identity; replacing only + // the handle and layer reset owner_handle to NULL and made + // the completed entity unavailable to scoped operations such + // as parametric constraints. + let common = old.common().clone(); + let mut new = entity; + *new.common_mut() = common; + *old = new; + self.tabs[i] + .scene + .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); + self.tabs[i].dirty = true; + } + if tracks_draw_anchor { + self.tabs[i].last_draw_anchor = Some(handle); + } + if finish { + self.finish_live_entity_history(i, handle); + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + } else { + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + } + } + + pub(super) fn handle_finalize_live_entity(&mut self, handle: Handle) { + let i = self.active_tab; + // The live geometry already matches the command's committed + // vertices. Close the deferred history image and UI state + // without publishing a redundant Modified delta. + self.finish_live_entity_history(i, handle); + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + } + + pub(super) fn handle_remove_live_entity(&mut self, handle: Handle) { + let i = self.active_tab; + // The command backed off below a valid entity (PLINE Undo at + // one remaining vertex): take the live entity out of the + // document, drop its provisional history entry and keep + // prompting. A later second point creates one fresh entry. + self.tabs[i].scene.erase_entities(&[handle]); + if self.tabs[i] + .last_draw_anchor + .is_some_and(|anchor_handle| anchor_handle == handle) + { + self.tabs[i].last_draw_anchor = None; + } + self.discard_last_undo_entry(i); + self.tabs[i].dirty = true; + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + } +} diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs new file mode 100644 index 000000000..82677691b --- /dev/null +++ b/src/app/command_driver/mod.rs @@ -0,0 +1,1697 @@ +//! Command-result driver: routes [`CmdResult`]s from interactive commands into host state. +//! +//! # Router (no wildcard) +//! +//! `apply_cmd_result_inner` is a thin exhaustive `match` over [`CmdResult`]: +//! every variant has an explicit arm and there is intentionally NO wildcard arm, +//! so adding a variant fails to compile until it is routed here. +//! +//! # Handler signature rule (T5) +//! +//! Router arms stay thin: each forwards to a `handle_*` method on `OpenCADStudio`. +//! - Payloads with **3 or fewer fields** are destructured at the router and passed +//! as individual arguments. +//! - **Wide payloads** (more than 3 fields) are passed whole via +//! `result @ Variant { .. }` and destructured inside the handler with `let-else`. +//! - **Or-pattern** arms (e.g. `Cancel | CancelForSpaceChange`) pass the enum itself +//! so the handler can tell the cases apart with `matches!`. +//! - Return type follows the arm's control flow so the router's post-match focus +//! bookkeeping runs exactly when it used to: `()` when the arm falls through, +//! `Task` when every path diverges with `return`, and +//! `Option>` (`Some` = return, `None` = fall through) when the arm +//! mixes both. `Relaunch`/`Dispatch` instead produce the `dispatched` task that +//! the router assigns. +//! +//! Handler bodies are verbatim 1:1 moves of the original match arms (plus the +//! mechanical `let i = self.active_tab;` prologue). +//! +//! # Adding a variant (T1 exhaustiveness guide) +//! +//! There is intentionally NO wildcard arm in the router, so adding a +//! [`CmdResult`] variant fails to compile until it is routed here. To add one: +//! 1. add an explicit arm in `apply_cmd_result_inner` following the T5 rule; +//! 2. put the `handle_*` body in the owning file below (extend the `impl` +//! block there — never inline a body in the router); +//! 3. if no file owns the new concern, extend this list and document the new +//! file here. +//! +//! # Router-only exception (T6) +//! +//! Only the router (`apply_cmd_result` / `apply_cmd_result_inner`), flow and +//! lifecycle arms, group/table arms, mleader arms, and helpers with callers in +//! more than one domain file live in `mod.rs`: `merge_dependencies` (also +//! called from `app::update::dialog`), `restore_pre_cmd_tangent` (called from +//! every domain file), and `entity_at_typed_point` (called from +//! `utilities.rs`). Everything else lives in its domain file. +//! +//! # File ownership map +//! +//! - `utilities.rs` — generic pre-match helpers. +//! - `parameters.rs` — parametric parameters, named-parameter editor rows, +//! dynamic dimensions, and the constraint-inference entry points +//! (`apply_continuous_constraints`, `apply_inferred_constraints`) shared by +//! the `constraint.rs` arms. +//! - `property_match.rs` — MATCHPROP/LAYMATCH + `match_special_props`. +//! - `entity_commit.rs` — entity/dimension/hatch/block commit arms. +//! - `solid3d.rs` — extrude/thicken/presspull/revolve/sweep/loft/shell/boolean/slice. +//! - `viewport.rs` — MVIEW/layout/plot/zoom/print arms. +//! - `transform.rs` — MOVE/COPY/ALIGN/STRETCH arms. +//! - `clipboard.rs` — COPYCLIP/PASTECLIP/PASTEORIG/PASTEBLOCK arms, +//! clipboard merge + xdict helpers; re-exports +//! `remap_ext_subtree_reference` for `app::commands::blocks`. +//! - `modify.rs` — LENGTHEN/DIVIDE/MEASURE/PEDIT/JOIN/BREAK/HATCHEDIT arms + +//! `apply_pedit_result`. (Entity BREAK itself delegates to +//! `break_cmd::break_entity`; the DIMBREAK free helpers live in `dim_edit`.) +//! - `text_edit.rs` — MTEXT/TEXT edit-session arms (incl. DDEDIT). +//! - `constraint.rs` — parametric/geometric constraint arms (moved LAST: +//! active development area). +//! - `dim_edit.rs` — DIMBREAK/DIMJOGLINE/DIMSPACE arms + `apply_dimspace`, +//! `dimension_auto_spacing`, and the dimbreak free helpers +//! (`DimBreakResult`, `wire_segments`, `segment_intersection_xy`, +//! `break_object_handle`, `remove/write_dimension_break_data`, +//! `break_reference`, `apply_dimbreak`). +//! - mleader arms + `apply_mleader_align`/`collect` helpers live in `mod.rs` +//! (router-only exception). + +use super::{Message, OpenCADStudio}; +use crate::command::{CmdResult, SelectionEntity, StepInput}; +use acadrust::Handle; +use iced::Task; + +mod utilities; +mod parameters; +mod tests; +mod property_match; +mod entity_commit; +mod solid3d; +mod viewport; +mod transform; +mod clipboard; +mod modify; +mod text_edit; +mod constraint; +mod dim_edit; + +pub(crate) use clipboard::remap_ext_subtree_reference; + +impl OpenCADStudio { + /// Applies one command result, then — when that result ended the active + /// command — drops the selection. Editing tools (MOVE, COPY, ROTATE, …) and + /// every other interactive command leave nothing selected once they finish, + /// so a follow-up edit doesn't silently reuse the previous working set. + /// + /// `Relaunch`/`Dispatch` are excepted: they end the front-end command only + /// to immediately start another and hand it a deliberate selection (the + /// pick-first selector relaunching MOVE on the picked set works this way). + /// Pure-selection commands (SELECTALL, QSELECT, …) run without an active + /// command, so `was_active` is false and their selection is preserved. + pub(super) fn apply_cmd_result(&mut self, mut result: CmdResult) -> Task { + let i = self.active_tab; + if let CmdResult::ReturnPoint(pt) = result { + self.tabs[i].scene.clear_preview_wire(); + if let Some(mut suspended) = self.tabs[i].suspended_cmd.take() { + self.last_point = Some(pt); + let next_res = suspended.on_point(pt); + self.tabs[i].active_cmd = Some(suspended); + result = next_res; + } else { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + return Task::none(); + } + } + let settings = self.tabs[self.active_tab] + .active_cmd + .as_ref() + .and_then(|command| command.sketch_settings()); + if let Some((sketch_type, increment, tolerance)) = settings { + let tab = &mut self.tabs[self.active_tab]; + let changed = tab.scene.document.header.sketch_type != sketch_type + || (tab.scene.document.header.sketch_increment - increment).abs() > f64::EPSILON + || (tab.scene.document.header.sketch_tolerance - tolerance).abs() > f64::EPSILON; + if changed { + crate::io::set_sketch_settings( + &mut tab.scene.document, + sketch_type, + increment, + tolerance, + ); + tab.dirty = true; + } + } + let mline_settings = self.tabs[self.active_tab] + .active_cmd + .as_ref() + .and_then(|command| command.mline_settings()); + if let Some((scale, justification, style_name, style_handle)) = mline_settings { + let tab = &mut self.tabs[self.active_tab]; + let header = &mut tab.scene.document.header; + let style_handle = style_handle.unwrap_or(acadrust::Handle::NULL); + let changed = (header.multiline_scale - scale).abs() > f64::EPSILON + || header.multiline_justification != justification + || !header.multiline_style.eq_ignore_ascii_case(&style_name) + || header.current_multiline_style_handle != style_handle; + if changed { + header.multiline_scale = scale; + header.multiline_justification = justification; + header.multiline_style = style_name; + header.current_multiline_style_handle = style_handle; + tab.dirty = true; + } + } + let was_active = self.tabs[self.active_tab].active_cmd.is_some(); + let preserve_selection = matches!( + &result, + CmdResult::Relaunch(..) + | CmdResult::Dispatch(..) + | CmdResult::SolidEdgeBlend { .. } + | CmdResult::SolidSubtract { .. } + | CmdResult::SliceEntities { .. } + | CmdResult::SliceSurfaceEntities { .. } + ); + let task = self.apply_cmd_result_inner(result); + let i = self.active_tab; + // A transparent zoom that just finished hands control back to the + // command it interrupted before the "command ended" bookkeeping below. + self.resume_transparent_parent(i); + let preview_hidden = self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.preview_hidden_handles().to_vec()) + .unwrap_or_default(); + self.tabs[i] + .scene + .set_command_preview_hidden(&preview_hidden); + if was_active + && !preserve_selection + && self.tabs[i].active_cmd.is_none() + && !self.tabs[i].scene.selected.is_empty() + { + // Remember the working set for the "Previous" selection keyword + // (#426) before dropping it. + self.tabs[i].prev_selection = self.tabs[i].scene.selected.iter().copied().collect(); + self.tabs[i].scene.deselect_all(); + self.refresh_properties(); + } + // A command just ended (any terminal result) — if it was the draw + // command an ADDSELECTED launched, revert the template-property override + // so CLAYER / CECOLOR / … are left unchanged (#239). No-op otherwise. + if was_active && self.tabs[i].active_cmd.is_none() { + self.tabs[i].scene.set_hover_highlight(None); + self.command_line.set_step_options(Vec::new()); + self.restore_add_selected_defaults(); + self.tabs[i].pending_pause_tokens = None; + } + if self.tabs[i].active_cmd.is_some() && self.tabs[i].pending_pause_tokens.is_some() { + let drain_task = self.drain_pending_pause_tokens(i); + Task::batch([task, drain_task]) + } else { + task + } + } + + /// Resolve the cell under `click` on table `handle` (or any table in the drawing + /// if `handle` is `Handle::NULL`), arm the cell indicator, and launch the cell + /// editor unless the cell's content is locked. Shared by the double-click shortcut + /// and the TABLEDIT point pick so both agree on grid coordinates and editor setup. + pub(crate) fn begin_table_cell_edit( + &mut self, + i: usize, + handle: acadrust::Handle, + click: glam::DVec3, + ) -> crate::modules::annotate::table_cmd::TableCellEditStart { + use crate::modules::annotate::table_cmd::{table_cell_at, TableCellEditStart}; + let target = if handle != acadrust::Handle::NULL { + self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(|entity| match entity { + acadrust::EntityType::Table(table) => Some((handle, table.clone())), + _ => None, + }) + } else { + // Find all tables whose grid contains `click`. + // Iterating document.entities() visits entities in draw order; + // taking the last match selects the table on top if they overlap. + let mut matched = None; + for entity in self.tabs[i].scene.document.entities() { + if let acadrust::EntityType::Table(table) = entity { + let h = table.common.handle; + let style = table.table_style_handle.and_then(|style_handle| { + self.tabs[i] + .scene + .document + .objects + .get(&style_handle) + .and_then(|object| match object { + acadrust::objects::ObjectType::TableStyle(style) => Some(style), + _ => None, + }) + }); + if table_cell_at(table, style, click).is_some() { + matched = Some((h, table.clone())); + } + } + } + matched + }; + let Some((handle, table)) = target else { + return TableCellEditStart::NoCell; + }; + let style = table.table_style_handle.and_then(|style_handle| { + self.tabs[i] + .scene + .document + .objects + .get(&style_handle) + .and_then(|object| match object { + acadrust::objects::ObjectType::TableStyle(style) => Some(style), + _ => None, + }) + }); + let Some(hit) = table_cell_at(&table, style, click) else { + return TableCellEditStart::NoCell; + }; + // Arm the Properties cell indicator whether or not the cell turns + // out to be editable (matches the double-click behavior). + let index = hit.index(&table); + self.tabs[i].properties.prop_vertex = index; + self.tabs[i].properties.prop_vertex_indicator_active = true; + crate::entities::table::set_prop_current_cell(index); + self.refresh_properties(); + if hit.locked { + return TableCellEditStart::LockedCell; + } + let command = crate::modules::annotate::table_cmd::TableCellEditCommand::new( + handle, &table, hit.row, hit.column, + ); + self.command_line + .push_info(&crate::command::CadCommand::prompt(&command)); + self.tabs[i].active_cmd = Some(Box::new(command)); + TableCellEditStart::Started + } + + /// Reports a refused command-line value and shows the prompt again. + fn reprompt_active_command(&mut self, i: usize, message: &str) { + self.command_line.push_error(message); + if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()) { + self.command_line.push_info(&prompt); + } + } + + /// Bring back the command a transparent zoom parked, once the zoom is + /// over (no command active any more). No-op otherwise. + pub(in crate::app) fn resume_transparent_parent(&mut self, i: usize) { + if !self.tabs[i].transparent_resume { + return; + } + if self.tabs[i].active_cmd.is_some() { + // The transparent prompt is still up (or the parent was already + // restored by a Cancel). + if self.tabs[i].suspended_cmd.is_none() { + self.tabs[i].transparent_resume = false; + } + return; + } + self.tabs[i].transparent_resume = false; + let Some(parent) = self.tabs[i].suspended_cmd.take() else { + return; + }; + self.tabs[i].active_cmd = Some(parent); + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + let opts = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.options()) + .unwrap_or_default(); + self.command_line.set_step_options(opts); + self.refresh_active_cmd_preview(i); + } + + pub(in crate::app) fn start_mtp_modifier(&mut self, i: usize) { + let parent = self.tabs[i].active_cmd.take(); + self.tabs[i].suspended_cmd = parent; + self.tabs[i].scene.clear_preview_wire(); + let cmd = crate::command::Mid2PointCommand::new(); + let prompt = crate::command::CadCommand::prompt(&cmd); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + self.command_line.push_info(&prompt); + self.command_line.set_step_options(Vec::new()); + self.refresh_active_cmd_preview(i); + } + + /// Apply a command replacement without deleting a one-to-one entity edit. + /// Splits and entity-type conversions still allocate replacement identities. + fn replace_command_entity( + &mut self, + tab: usize, + handle: Handle, + mut entities: Vec, + ) -> Vec { + let same_type = entities.len() == 1 + && self.tabs[tab] + .scene + .document + .get_entity(handle) + .is_some_and(|original| { + std::mem::discriminant(original) == std::mem::discriminant(&entities[0]) + }); + let handles = if same_type { + let mut entity = entities.pop().expect("one replacement"); + entity.common_mut().handle = handle; + // Scene::update_entity also retains the live owning block and + // refreshes dependent render caches without erase notifications. + if self.tabs[tab].scene.update_entity(entity) { + vec![handle] + } else { + Vec::new() + } + } else { + let owner = self.tabs[tab] + .scene + .document + .get_entity(handle) + .map(|entity| entity.common().owner_handle); + self.tabs[tab].scene.erase_entities(&[handle]); + entities + .into_iter() + .map(|mut entity| { + entity.common_mut().handle = Handle::NULL; + if let Some(owner) = owner { + entity.common_mut().owner_handle = owner; + } + self.tabs[tab].scene.add_entity(entity) + }) + .collect() + }; + for &updated in &handles { + if matches!( + self.tabs[tab].scene.document.get_entity(updated), + Some(acadrust::EntityType::Dimension(_)) + ) { + self.tabs[tab].scene.invalidate_dim_block_recorded(updated); + } + } + handles + } + + + fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task { + let i = self.active_tab; + if let Some(bind) = self.tabs[i] + .active_cmd + .as_ref() + .and_then(|command| command.nested_copy_bind_setting()) + { + if self.ncopy_bind != bind { + self.ncopy_bind = bind; + self.persist_settings_if_changed(); + } + } + let preserve_commit_style = self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.preserve_commit_style()); + let preserve_commit_layer = self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.preserve_commit_layer()); + // Task produced by a command the arm dispatches; it must reach the + // runtime or messages such as a chosen render mode are dropped. + let mut dispatched = Task::none(); + match result { + CmdResult::OpenAutoConstrainSettings => { + self.handle_open_auto_constrain_settings(); + } + CmdResult::NeedPoint => { + if let Some(task) = self.handle_need_point() { + return task; + } + } + CmdResult::Preview(wire) => { + self.handle_preview(wire); + } + CmdResult::InterimWire(wire) => { + self.handle_interim_wire(wire); + } + CmdResult::CommitEntity(entity) => { + if let Some(task) = self.handle_commit_entity(entity, preserve_commit_layer) { + return task; + } + } + CmdResult::CommitEntities(entities) => { + if let Some(task) = self.handle_commit_entities( + entities, + preserve_commit_style, + preserve_commit_layer, + ) { + return task; + } + } + CmdResult::CommitEntitiesAndExit(entities) => { + self.handle_commit_entities_and_exit( + entities, + preserve_commit_style, + preserve_commit_layer, + ); + } + CmdResult::MviewCreate { + viewport, + preserve_view, + } => { + self.handle_mview_create(viewport, preserve_view); + } + CmdResult::MviewCreateClipped { + boundary, + boundary_handle, + } => { + if let Some(task) = self.handle_mview_create_clipped(boundary, boundary_handle) { + return task; + } + } + CmdResult::WipeoutFromPolyline { + handle, + erase_source, + } => { + if let Some(task) = self.handle_wipeout_from_polyline(handle, erase_source) { + return task; + } + } + CmdResult::MviewSwitchLayout(layout) => return self.handle_mview_switch_layout(layout), + CmdResult::MviewCancelToLayout(layout) => { + return self.handle_mview_cancel_to_layout(layout) + } + CmdResult::TransformSelected(handles, transform) => { + if let Some(task) = self.handle_transform_selected(handles, transform) { + return task; + } + } + CmdResult::CopySelected(handles, transform) => { + if let Some(task) = self.handle_copy_selected(handles, transform) { + return task; + } + } + CmdResult::CopyToClipboard { handles, base } => { + self.handle_copy_to_clipboard(handles, base); + } + CmdResult::CommitAndExit(entity) => { + if let Some(task) = self.handle_commit_and_exit(entity) { + return task; + } + } + result @ CmdResult::CommitDimension { .. } => { + if let Some(task) = self.handle_commit_dimension(result) { + return task; + } + } + CmdResult::CommitDimensionsAndExit(dimensions) => { + self.handle_commit_dimensions_and_exit(dimensions); + } + CmdResult::SetQuickDimensionSnapPriority(priority) => { + self.handle_set_quick_dimension_snap_priority(priority); + } + CmdResult::AlignMLeaders { handles, from, to } => { + self.handle_align_mleaders(handles, from, to); + } + CmdResult::CollectMLeaders { handles, point } => { + self.handle_collect_mleaders(handles, point); + } + result @ CmdResult::CommitSolid { .. } => { + self.handle_commit_solid(result); + } + CmdResult::CommitAndEditText(entity) => { + if let Some(task) = self.handle_commit_and_edit_text(entity) { + return task; + } + } + CmdResult::CommitManyAndEditText { + entities, + edit_index, + open_editor, + } => { + if let Some(task) = + self.handle_commit_many_and_edit_text(entities, edit_index, open_editor) + { + return task; + } + } + CmdResult::CreateBlock { + handles, + name, + base, + } => { + if let Some(task) = self.handle_create_block(handles, name, base) { + return task; + } + } + CmdResult::CreateBlockWithOptions { options } => { + if let Some(task) = self.handle_create_block_with_options(options) { + return task; + } + } + CmdResult::CommitHatch(hatch) => { + self.handle_commit_hatch(hatch); + } + CmdResult::CommitStyledHatch { + hatch, + color, + transparency, + } => { + self.handle_commit_styled_hatch(hatch, color, transparency); + } + CmdResult::CommitHatchWithBoundaries { + hatch, + boundaries, + entity_style, + } => { + self.handle_commit_hatch_with_boundaries(hatch, boundaries, entity_style); + } + CmdResult::CommitHatches { + hatches, + entity_style, + } => { + self.handle_commit_hatches(hatches, entity_style); + } + CmdResult::BatchCopy(handles, transforms) => { + if let Some(task) = self.handle_batch_copy(handles, transforms) { + return task; + } + } + CmdResult::ReplaceMany(replacements, additions) => { + if let Some(task) = self.handle_replace_many(replacements, additions) { + return task; + } + } + CmdResult::ReplaceManyContinue(replacements) => { + if let Some(task) = self.handle_replace_many_continue(replacements) { + return task; + } + } + CmdResult::UpdateEntityAndFinish { handle, entity } => { + if let Some(task) = self.handle_update_entity_and_finish(handle, entity) { + return task; + } + } + result @ CmdResult::AddParametricConstraint { .. } => { + if let Some(task) = self.handle_add_parametric_constraint(result) { + return task; + } + } + result @ CmdResult::AddEqualConstraint { .. } => { + if let Some(task) = self.handle_add_equal_constraint(result) { + return task; + } + } + CmdResult::CheckConstraintPoint(pick) => { + return self.handle_check_constraint_point(pick) + } + result @ CmdResult::AddDimensionalConstraint { .. } => { + if let Some(task) = self.handle_add_dimensional_constraint(result) { + return task; + } + } + CmdResult::CancelWithMessage(message) => { + return self.handle_cancel_with_message(message) + } + result @ CmdResult::AddRadialConstraint { .. } => { + if let Some(task) = self.handle_add_radial_constraint(result) { + return task; + } + } + result @ CmdResult::AddAngularConstraint { .. } => { + if let Some(task) = self.handle_add_angular_constraint(result) { + return task; + } + } + result @ CmdResult::MakeParallel { .. } => return self.handle_make_parallel(result), + CmdResult::AddFixedConstraint(pick) => return self.handle_add_fixed_constraint(pick), + CmdResult::CheckHorizontalPoint { kind, pick } => { + return self.handle_check_horizontal_point(kind, pick) + } + result @ CmdResult::AddHorizontalConstraint { .. } => { + if let Some(task) = self.handle_add_horizontal_constraint(result) { + return task; + } + } + CmdResult::AddSymmetricConstraint { + selection, + axis, + label, + } => { + if let Some(task) = self.handle_add_symmetric_constraint(selection, axis, label) { + return task; + } + } + result @ CmdResult::AddPerpendicularConstraint { .. } => { + if let Some(task) = self.handle_add_perpendicular_constraint(result) { + return task; + } + } + CmdResult::AddTangentConstraint { + first, + second, + label, + } => { + if let Some(task) = self.handle_add_tangent_constraint(first, second, label) { + return task; + } + } + CmdResult::AddConcentricConstraint { + first, + second, + label, + } => { + if let Some(task) = self.handle_add_concentric_constraint(first, second, label) { + return task; + } + } + result @ CmdResult::AddCoincidentConstraint { .. } => { + if let Some(task) = self.handle_add_coincident_constraint(result) { + return task; + } + } + CmdResult::AddAutoCoincidentConstraints { handles } => { + self.handle_add_auto_coincident_constraints(handles); + } + result @ CmdResult::AddPointOnEntityConstraint { .. } => { + if let Some(task) = self.handle_add_point_on_entity_constraint(result) { + return task; + } + } + CmdResult::AddEqualDistanceConstraint { points, label } => { + if let Some(task) = self.handle_add_equal_distance_constraint(points, label) { + return task; + } + } + CmdResult::ReassociateCenterMark { + target, + source, + point, + } => { + if let Some(task) = self.handle_reassociate_center_mark(target, source, point) { + return task; + } + } + CmdResult::EditDimensionBreak { + dimensions, + operation, + } => { + self.handle_edit_dimension_break(dimensions, operation); + } + CmdResult::EditDimensionJog { dimension, point } => { + self.handle_edit_dimension_jog(dimension, point); + } + CmdResult::SpaceDimensions { + base, + others, + spacing, + } => { + self.handle_space_dimensions(base, others, spacing); + } + CmdResult::ReplaceEntity(handle, new_entities) => { + if let Some(task) = self.handle_replace_entity(handle, new_entities) { + return task; + } + } + CmdResult::AttreqNeeded { block_name } => { + if let Some(task) = self.handle_attreq_needed(block_name) { + return task; + } + } + CmdResult::CommitLiveEntity(entity) => { + self.handle_commit_live_entity(entity); + } + CmdResult::UpdateLiveEntity { + handle, + entity, + finish, + } => { + self.handle_update_live_entity(handle, entity, finish); + } + CmdResult::FinalizeLiveEntity(handle) => { + self.handle_finalize_live_entity(handle); + } + CmdResult::RemoveLiveEntity(handle) => { + self.handle_remove_live_entity(handle); + } + cancel @ (CmdResult::Cancel | CmdResult::CancelForSpaceChange) => { + self.handle_cancel(cancel); + } + CmdResult::SelectByPath { + path, + closed, + crossing, + } => return self.handle_select_by_path(path, closed, crossing), + CmdResult::Relaunch(cmd, handles) => { + dispatched = self.handle_relaunch(cmd, handles); + } + CmdResult::Dispatch(cmd) => { + dispatched = self.handle_dispatch(cmd); + } + CmdResult::EditTableCell { handle, point } => { + if let Some(task) = self.handle_edit_table_cell(handle, point) { + return task; + } + } + CmdResult::MatchEntityLayer { dest, src } => { + self.handle_match_entity_layer(dest, src); + } + CmdResult::MatchProperties { dest, src } => { + if let Some(task) = self.handle_match_properties(dest, src) { + return task; + } + } + CmdResult::PasteClipboard { base_pt } => { + self.handle_paste_clipboard(base_pt); + } + CmdResult::CreateGroup { handles, name } => { + if let Some(task) = self.handle_create_group(handles, name) { + return task; + } + } + CmdResult::DeleteGroups { handles } => { + if let Some(task) = self.handle_delete_groups(handles) { + return task; + } + } + CmdResult::VpLayerUpdate { + vp_handle, + freeze, + thaw, + } => { + self.handle_vp_layer_update(vp_handle, freeze, thaw); + } + + CmdResult::ZoomToWindow { p1, p2 } => { + self.handle_zoom_to_window(p1, p2); + } + CmdResult::Measurement(msg) => { + self.handle_measurement(msg); + } + CmdResult::ReportMeasurement(msg) => { + self.handle_report_measurement(msg); + } + CmdResult::ReportError(msg) => { + self.handle_report_error(msg); + } + CmdResult::ReportMeasurementAndDeselect(msg) => { + self.handle_report_measurement_and_deselect(msg); + } + CmdResult::DeselectAndContinue => { + self.handle_deselect_and_continue(); + } + result @ CmdResult::AlignSelected { .. } => { + self.handle_align_selected(result); + } + CmdResult::LengthenEntity { handle, pick_pt, mode } => { + if let Some(task) = self.handle_lengthen_entity(handle, pick_pt, mode) { + return task; + } + } + CmdResult::DivideEntity { handle, n, marker } => { + self.handle_divide_entity(handle, n, marker); + } + result @ CmdResult::MeasureEntity { .. } => { + self.handle_measure_entity(result); + } + CmdResult::PeditOp { handle, op } => return self.handle_pedit_op(handle, op), + CmdResult::JoinToSource { source, handles } => { + if let Some(task) = self.handle_join_to_source(source, handles) { + return task; + } + } + CmdResult::JoinEntities(handles) => { + if let Some(task) = self.handle_join_entities(handles) { + return task; + } + } + CmdResult::BreakEntity { handle, p1, p2 } => { + if let Some(task) = self.handle_break_entity(handle, p1, p2) { + return task; + } + } + CmdResult::SetPlotWindow { p1, p2 } => { + self.handle_set_plot_window(p1, p2); + } + CmdResult::QuickPrint(handles) => return self.handle_quick_print(handles), + CmdResult::StretchWindow { handles, windows } => { + self.handle_stretch_window(handles, windows); + } + CmdResult::StretchEntities { handles, windows, delta } => { + if let Some(task) = self.handle_stretch_entities(handles, windows, delta) { + return task; + } + } + // ── EXTRUDE ──────────────────────────────────────────────────── + result @ CmdResult::ExtrudeEntities { .. } => { + if let Some(task) = self.handle_extrude_entities(result) { + return task; + } + } + + CmdResult::ThickenEntities { handles, distance } => { + if let Some(task) = self.handle_thicken_entities(handles, distance) { + return task; + } + } + + result @ CmdResult::PresspullPick { .. } => { + self.handle_presspull_pick(result); + } + CmdResult::PresspullApply { targets, distance, color: _ } => { + self.handle_presspull_apply(targets, distance); + } + + // ── REVOLVE ──────────────────────────────────────────────────── + result @ CmdResult::RevolveEntities { .. } => { + if let Some(task) = self.handle_revolve_entities(result) { + return task; + } + } + // ── SWEEP ───────────────────────────────────────────────────── + result @ CmdResult::SweepEntities { .. } => { + self.handle_sweep_entities(result); + } + + // ── LOFT ────────────────────────────────────────────────────── + result @ CmdResult::LoftEntities { .. } => { + if let Some(task) = self.handle_loft_entities(result) { + return task; + } + } + + result @ CmdResult::SolidEdgeBlend { .. } => return self.handle_solid_edge_blend(result), + + CmdResult::SolidShell { handle, actions, distance } => return self.handle_solid_shell(handle, actions, distance), + + CmdResult::SolidSubtract { bases, cutters, convert_meshes } => return self.handle_solid_subtract(bases, cutters, convert_meshes), + + CmdResult::SliceEntities { targets, plane, keep_point } => return self.handle_slice_entities(targets, plane, keep_point), + + CmdResult::SliceSurfaceEntities { targets, cutter, keep_point } => return self.handle_slice_surface_entities(targets, cutter, keep_point), + + result @ CmdResult::HatcheditApply { .. } => { + if let Some(task) = self.handle_hatchedit_apply(result) { + return task; + } + } + result @ CmdResult::OpenMTextEditor { .. } => { + self.handle_open_mtext_editor(result); + } + CmdResult::SuspendForMTextInput { + pos, + initial, + height, + } => { + self.handle_suspend_for_mtext_input(pos, initial, height); + } + result @ CmdResult::OpenTextEditor { .. } => { + self.handle_open_text_editor(result); + } + CmdResult::SuspendForTextInput { pos, entity } => { + self.handle_suspend_for_text_input(pos, entity); + } + CmdResult::EditTextEntity { handle } => return self.handle_edit_text_entity(handle), + CmdResult::SuspendForTextEdit { handle } => { + return self.handle_suspend_for_text_edit(handle) + } + CmdResult::UndoDocument => { + self.handle_undo_document(); + } + CmdResult::SetTexteditMode(val) => { + self.handle_set_textedit_mode(val); + } + CmdResult::EditDimensions { handles, operation } => { + self.handle_edit_dimensions(handles, operation); + } + CmdResult::DdeditEntity { handle, new_text } => { + if let Some(task) = self.handle_ddedit_entity(handle, new_text) { + return task; + } + } + CmdResult::ReturnPoint(_) => { + self.handle_return_point(); + } + } + // When no command is running the ribbon tool button still has to + // visually deactivate. Keyboard focus is assigned below to whichever + // editor currently owns typed input. + if self.tabs[i].active_cmd.is_none() { + self.ribbon.deactivate_tool(); + } + // The rich text canvas owns keyboard editing itself. Leaving the + // hidden command input focused would make it consume Left/Right before + // the editor can handle them. + let focus = if self.mtext_editor.is_some() { + self.unfocus_widgets() + } else if self.text_inline.is_some() { + // The in-place TEXT editor needs keyboard focus on its own field. + iced::widget::operation::focus(iced::widget::Id::new(super::view::TEXT_INLINE_ID)) + } else { + self.focus_cmd_input() + }; + Task::batch([dispatched, focus]) + } + + // --- C2a handlers: flow / lifecycle (thin-router targets, bodies moved verbatim) --- + + fn handle_need_point(&mut self) -> Option> { + let i = self.active_tab; + // ATTEDIT finished its entity pick: hand the chosen block off to + // the attribute editor dialog and end the command (open_attribute + // _editor reports "no attributes" / "select a block" as needed). + let attedit_handle = self.tabs[i] + .active_cmd + .as_ref() + .and_then(|c| c.attedit_pending_handle()); + if let Some(ins_handle) = attedit_handle { + self.tabs[i].active_cmd = None; + self.command_line.set_step_options(Vec::new()); + self.open_attribute_editor(ins_handle); + return Some(Task::none()); + } + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + let opts = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.options()) + .unwrap_or_default(); + self.command_line.set_step_options(opts); + if !self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|c| c.entity_pick_highlights_hover()) + { + self.tabs[i].scene.set_hover_highlight(None); + } + // The command may have advanced to a step with a different + // dynamic-input shape (e.g. FILLET object-pick → radius entry). + // Rebuild the fields now so the matching box appears immediately + // and typed digits land in it rather than the command line, + // instead of waiting for the next cursor move to resync. + self.sync_dyn_fields(); + self.refresh_area_preview(i); + None + } + + fn handle_preview(&mut self, wire: crate::scene::model::wire_model::WireModel) { + let i = self.active_tab; + self.tabs[i].scene.set_preview_wires(vec![wire]); + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + } + + fn handle_interim_wire(&mut self, wire: crate::scene::model::wire_model::WireModel) { + let i = self.active_tab; + self.tabs[i].scene.set_interim_wire(wire); + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + } + + fn handle_cancel(&mut self, cancel: CmdResult) { + let i = self.active_tab; + let space_changed = matches!(cancel, CmdResult::CancelForSpaceChange); + self.tabs[i].scene.clear_preview_wire(); + if !space_changed && self.tabs[i].suspended_cmd.is_some() { + self.tabs[i].active_cmd = self.tabs[i].suspended_cmd.take(); + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + let opts = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.options()) + .unwrap_or_default(); + self.command_line.set_step_options(opts); + self.refresh_active_cmd_preview(i); + } else { + self.tabs[i].suspended_cmd = None; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + self.command_line.push_info( + crate::t!(if space_changed { + "Command cancelled because the active drawing space changed." + } else { + "Command cancelled." + }) + .as_ref(), + ); + } + } + + fn handle_cancel_with_message(&mut self, message: String) -> Task { + self.command_line.push_error(&message); + self.apply_cmd_result(CmdResult::Cancel) + } + + fn handle_select_by_path( + &mut self, + path: Vec<[f64; 2]>, + closed: bool, + crossing: bool, + ) -> Task { + let i = self.active_tab; + // The command picked the path; the hit test lives here, where + // the camera and the drawing's geometry are. Everything after + // matches what a lasso does, Remove included. + let canvas = self.tabs[i].scene.selection.borrow().vp_size; + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: canvas.0, + height: canvas.1, + }; + let edit_cam = self.tabs[i] + .scene + .viewport_edit_frame(canvas) + .map(|(cam, _)| cam); + let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds); + let screen: Vec = path + .iter() + .map(|p| { + crate::scene::pick::hit_test::world_to_screen( + glam::DVec3::new(p[0], p[1], 0.0), + view_rot, + eye, + bounds, + ) + }) + .collect(); + let handles = self.tabs[i].scene.path_hit_handles( + &screen, + crossing, + !closed, + all_wires, + view_rot, + eye, + bounds, + |point| self.cursor_model_point(i, &edit_cam, point, bounds), + ); + if self.select_remove_mode { + for h in &handles { + self.tabs[i].scene.deselect_entity(*h); + } + } else { + for h in &handles { + self.tabs[i].scene.select_entity(*h, false); + } + self.tabs[i].scene.expand_selection_for_groups(&handles); + } + self.refresh_properties(); + let selected: Vec = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .map(|(h, _)| h) + .collect(); + let count = handles.len(); + self.command_line + .push_info(crate::tf!("{count} object(s) found.").as_ref()); + self.feed_command(StepInput::SelectionComplete(selected)) + } + + fn handle_relaunch(&mut self, cmd: String, handles: Vec) -> Task { + let i = self.active_tab; + self.tabs[i].scene.deselect_all(); + for h in &handles { + self.tabs[i].scene.select_entity(*h, false); + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + self.dispatch_command(&cmd) + } + + fn handle_dispatch(&mut self, cmd: String) -> Task { + let i = self.active_tab; + // End this interactive front-end, then run the assembled command + // through the normal dispatcher. Selection is left untouched. + // The option picked in DIMCONSTRAINT becomes its next default; + // the same command run on its own leaves the default alone. + let from_menu = self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.name() == "DIMCONSTRAINT"); + if from_menu { + match cmd.as_str() { + "DCRADIUS" => self.dim_constraint_last = "Radius", + "DCDIAMETER" => self.dim_constraint_last = "Diameter", + "DCCONVERT" => self.dim_constraint_last = "Convert", + _ => {} + } + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + self.dispatch_command(&cmd) + } + + fn handle_measurement(&mut self, msg: String) { + let i = self.active_tab; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + self.command_line.push_output(&msg); + } + + fn handle_report_measurement(&mut self, msg: String) { + let i = self.active_tab; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.refresh_area_preview(i); + self.command_line.push_output(&msg); + if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()) { + self.command_line.push_info(&prompt); + } + } + + fn handle_report_error(&mut self, msg: String) { + let i = self.active_tab; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.command_line.push_error(&msg); + if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()) { + self.command_line.push_info(&prompt); + } + } + + fn handle_report_measurement_and_deselect(&mut self, msg: String) { + let i = self.active_tab; + self.tabs[i].snap_result = None; + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.clear_preview_wire(); + self.refresh_area_preview(i); + self.refresh_properties(); + self.command_line.push_output(&msg); + if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()) { + self.command_line.push_info(&prompt); + } + } + + fn handle_deselect_and_continue(&mut self) { + let i = self.active_tab; + self.tabs[i].snap_result = None; + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.clear_preview_wire(); + self.refresh_area_preview(i); + self.refresh_properties(); + if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()) { + self.command_line.push_info(&prompt); + } + } + + fn handle_undo_document(&mut self) { + let i = self.active_tab; + let active = self.tabs[i].active_cmd.take(); + self.undo_active_tab(); + self.tabs[i].active_cmd = active; + if self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.name() == "PEDIT") + { + let entities: Vec<_> = self.tabs[i].scene.document.entities().cloned().collect(); + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + for entity in entities { + command.inject_picked_entity(entity); + } + } + } + { + let tab = &mut self.tabs[i]; + if let Some(command) = tab.active_cmd.as_mut() { + command.on_document_undone(&tab.scene.document); + } + } + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + } + + fn handle_return_point(&mut self) { + let i = self.active_tab; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + } + // --- C2a handlers: group / table (thin-router targets, bodies moved verbatim) --- + + fn handle_create_group( + &mut self, + mut handles: Vec, + name: String, + ) -> Option> { + let i = self.active_tab; + handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + if handles.is_empty() { + return Some(Task::none()); + } + let undo = self.begin_group_undo(i, "GROUP"); + self.tabs[i].scene.create_group(name.clone(), handles); + self.tabs[i].dirty = true; + self.commit_group_undo(i, undo); + self.command_line + .push_info(crate::tf!("Group \"{}\" created.", name).as_ref()); + None + } + + fn handle_delete_groups(&mut self, mut handles: Vec) -> Option> { + let i = self.active_tab; + handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + if handles.is_empty() { + return Some(Task::none()); + } + let undo = self.begin_group_undo(i, "UNGROUP"); + let count = self.tabs[i].scene.delete_groups_containing(&handles); + self.tabs[i].dirty = true; + self.commit_group_undo(i, undo); + if count > 0 { + self.command_line + .push_info(crate::tf!("{} group(s) dissolved.", count).as_ref()); + } else { + self.command_line + .push_info(crate::t!("No groups found for selected objects.").as_ref()); + } + None + } + + fn handle_edit_table_cell( + &mut self, + handle: Handle, + point: glam::DVec3, + ) -> Option> { + let i = self.active_tab; + // TABLEDIT's pick: end the pick phase and hand (table, point) + // to the shared cell-edit launcher. A miss or a locked cell + // re-prompts until a valid table cell is selected. + use crate::modules::annotate::table_cmd::{table_cell_at, TableCellEditStart}; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + + if handle == acadrust::Handle::NULL && self.selection_cycling { + let mut candidates = Vec::new(); + for entity in self.tabs[i].scene.document.entities() { + if let acadrust::EntityType::Table(table) = entity { + let h = table.common.handle; + let style = table.table_style_handle.and_then(|style_handle| { + self.tabs[i] + .scene + .document + .objects + .get(&style_handle) + .and_then(|object| match object { + acadrust::objects::ObjectType::TableStyle(style) => Some(style), + _ => None, + }) + }); + if table_cell_at(table, style, point).is_some() { + candidates.push(h); + } + } + } + if candidates.len() >= 2 { + self.cycle_candidates = Some((self.quick_properties_anchor, candidates)); + return Some(Task::none()); + } + } + + match self.begin_table_cell_edit(i, handle, point) { + TableCellEditStart::Started => {} + TableCellEditStart::LockedCell => { + self.command_line + .push_error(crate::t!("The cell is content locked.").as_ref()); + let _ = self.dispatch_command("TABLEDIT"); + } + TableCellEditStart::NoCell => { + self.command_line + .push_error(crate::t!("No editable table cell picked.").as_ref()); + let _ = self.dispatch_command("TABLEDIT"); + } + } + None + } + // --- C2a handlers: mleader (thin-router targets, bodies moved verbatim) --- + + fn handle_set_quick_dimension_snap_priority(&mut self, priority: u8) { + let i = self.active_tab; + self.quick_dimension_snap_priority = priority.min(1); + self.persist_settings_if_changed(); + let prompt = self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.prompt()); + if let Some(prompt) = prompt { + self.command_line.push_info(&prompt); + } + } + + fn handle_align_mleaders( + &mut self, + mut handles: Vec, + from: glam::DVec3, + to: glam::DVec3, + ) { + let i = self.active_tab; + handles.retain(|handle| { + !self.tabs[i].scene.is_layer_locked(*handle) + && matches!( + self.tabs[i].scene.document.get_entity(*handle), + Some(acadrust::EntityType::MultiLeader(_)) + ) + }); + if !handles.is_empty() { + self.push_undo_snapshot(i, "MLEADERALIGN"); + if apply_mleader_align(&mut self.tabs[i].scene, &handles, from, to) { + self.tabs[i].dirty = true; + self.command_line + .push_output(crate::t!("MLEADERALIGN Leaders aligned.").as_ref()); + } + } + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + } + + fn handle_collect_mleaders(&mut self, handles: Vec, point: glam::DVec3) { + let i = self.active_tab; + let compatible = compatible_mleader_collect_handles(&self.tabs[i].scene, &handles); + if compatible.len() >= 2 { + self.push_undo_snapshot(i, "MLEADERCOLLECT"); + if apply_mleader_collect(&mut self.tabs[i].scene, &compatible, point) { + self.tabs[i].dirty = true; + self.command_line + .push_output(crate::t!("MLEADERCOLLECT Leaders collected.").as_ref()); + } + } + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + } + // --- C2a handlers: property_match (thin-router targets, bodies moved verbatim) --- + + + // --- C2a handlers: viewport (thin-router targets, bodies moved verbatim) --- + + + // --- C2a handlers: text_edit (thin-router targets, bodies moved verbatim) --- + + + // --- C2b handlers: entity_commit (thin-router targets, bodies moved verbatim) --- + + + // --- C2b handlers: clipboard (thin-router targets, bodies moved verbatim) --- + + + /// Restore the tangent-snap / ortho state that was in effect before the command started. + /// Recreate clipboard-dependency records (layer / linetype / text + dim + /// style) in tab `i`'s document for any the copied entities reference but + /// this drawing doesn't already have. Each recreated record gets a fresh + /// handle from the target document so it can't collide with an existing + /// one. No-op for same-document pastes (the records already exist). (#129) + pub(super) fn merge_dependencies(&mut self, i: usize, deps: &crate::app::ClipboardDeps) { + use acadrust::TableEntry; + if deps.is_empty() { + return; + } + let doc = &mut self.tabs[i].scene.document; + for rec in &deps.layers { + if !doc.layers.contains(rec.name()) { + let mut r = rec.clone(); + r.set_handle(doc.allocate_handle()); + let _ = doc.layers.add(r); + } + } + for rec in &deps.linetypes { + if !doc.line_types.contains(rec.name()) { + let mut r = rec.clone(); + r.set_handle(doc.allocate_handle()); + let _ = doc.line_types.add(r); + } + } + for rec in &deps.text_styles { + if !doc.text_styles.contains(rec.name()) { + let mut r = rec.clone(); + r.set_handle(doc.allocate_handle()); + let _ = doc.text_styles.add(r); + } + } + for rec in &deps.dim_styles { + if !doc.dim_styles.contains(rec.name()) { + let mut r = rec.clone(); + r.set_handle(doc.allocate_handle()); + let _ = doc.dim_styles.add(r); + } + } + } + + + // --- C2d handlers: constraint (thin-router targets, bodies moved verbatim) --- + + + // --- C2d handlers: dim_edit (thin-router targets, bodies moved verbatim) --- + + + pub(super) fn restore_pre_cmd_tangent(&mut self) { + if let Some(was_on) = self.pre_cmd_tangent.take() { + if !was_on { + self.snapper.enabled.remove(&crate::snap::SnapType::Tangent); + } + } + if self.rect_suppressed_ortho { + self.rect_suppressed_ortho = false; + self.ortho_mode = true; + self.polar_mode = false; + } + } +} + +/// Clone one captured xdictionary subtree into `doc` with fresh handles, +/// remapping every internal reference (and the owning entity, when known), +/// returning the new root handle. `allocate_handle` advances the document's +/// handle counter — `next_handle()` only peeks, so reusing it would hand every +/// object the same handle and collapse the dictionary chain. +/// The entity a typed coordinate lands on while an object is asked for: +/// the nearest planar curve of the edited space within a small share of +/// that space's extent, the way a pick box takes the object under a click. +fn entity_at_typed_point( + document: &acadrust::CadDocument, + owner: Handle, + point: glam::DVec3, +) -> Option { + let mut extent = 0.0f64; + let mut nearest: Option<(f64, Handle)> = None; + for entity in document.entities() { + let common = entity.common(); + if common.owner_handle != owner { + continue; + } + let bounds = entity.as_entity().bounding_box(); + extent = extent + .max((bounds.max.x - bounds.min.x).abs()) + .max((bounds.max.y - bounds.min.y).abs()); + let distance = match crate::scene::viewport_dimension_pick::planar_pick_distance(entity, point) { + Some(distance) => distance, + // A text or an ellipse answers by its extent, the way a click + // inside it picks it. + None if matches!( + entity, + acadrust::EntityType::Text(_) + | acadrust::EntityType::MText(_) + | acadrust::EntityType::Ellipse(_) + ) => + { + let dx = (bounds.min.x - point.x).max(point.x - bounds.max.x).max(0.0); + let dy = (bounds.min.y - point.y).max(point.y - bounds.max.y).max(0.0); + dx.hypot(dy) + } + None => continue, + }; + if nearest.is_none_or(|(best, _)| distance < best) { + nearest = Some((distance, common.handle)); + } + } + let tolerance = (extent * 0.002).max(1.0e-9); + nearest + .filter(|(distance, _)| *distance <= tolerance) + .map(|(_, handle)| handle) +} + + +// ── DIMSPACE helper ─────────────────────────────────────────────────────────── + + +fn apply_mleader_align( + scene: &mut crate::scene::Scene, + handles: &[acadrust::Handle], + from: glam::DVec3, + to: glam::DVec3, +) -> bool { + use cadkernel::geom2d::{closest_point, Curve, Vec2, XLine}; + + let Some(direction) = Vec2::new(to.x - from.x, to.y - from.y).normalize() else { + return false; + }; + let line = Curve::XLine(XLine { + base: [from.x, from.y], + direction: direction.into(), + }); + let mut changed = Vec::new(); + for &handle in handles { + if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity_mut(handle) { + let old = ml.context.content_base_point; + let projected = closest_point(&line, [old.x, old.y]).point; + let new_x = projected[0]; + let new_y = projected[1]; + let shift_x = new_x - old.x; + let shift_y = new_y - old.y; + if shift_x.abs() <= 1.0e-12 && shift_y.abs() <= 1.0e-12 { + continue; + } + ml.context.content_base_point.x = new_x; + ml.context.content_base_point.y = new_y; + ml.context.text_location.x += shift_x; + ml.context.text_location.y += shift_y; + ml.context.block_content_location.x += shift_x; + ml.context.block_content_location.y += shift_y; + for root in &mut ml.context.leader_roots { + root.connection_point.x += shift_x; + root.connection_point.y += shift_y; + } + changed.push((handle, crate::scene::ChangeKind::Modified)); + } + } + if !changed.is_empty() { + scene.bump_entities(&changed); + } + !changed.is_empty() +} + +fn compatible_mleader_collect_handles( + scene: &crate::scene::Scene, + handles: &[acadrust::Handle], +) -> Vec { + let Some((base_block, base_style)) = handles.iter().find_map(|handle| { + if scene.is_layer_locked(*handle) { + return None; + } + let acadrust::EntityType::MultiLeader(leader) = scene.document.get_entity(*handle)? else { + return None; + }; + (leader.content_type == acadrust::entities::LeaderContentType::Block) + .then_some((leader.block_content_handle, leader.style_handle)) + }) else { + return Vec::new(); + }; + let mut compatible = Vec::new(); + for handle in handles.iter().copied() { + if compatible.contains(&handle) { + continue; + } + if !scene.is_layer_locked(handle) + && matches!( + scene.document.get_entity(handle), + Some(acadrust::EntityType::MultiLeader(leader)) + if leader.content_type == acadrust::entities::LeaderContentType::Block + && leader.block_content_handle == base_block + && leader.style_handle == base_style + ) + { + compatible.push(handle); + } + } + compatible +} + +fn apply_mleader_collect( + scene: &mut crate::scene::Scene, + handles: &[acadrust::Handle], + point: glam::DVec3, +) -> bool { + if handles.len() < 2 { + return false; + } + let px = point.x; + let py = point.y; + let Some(acadrust::EntityType::MultiLeader(base)) = scene.document.get_entity(handles[0]) + else { + return false; + }; + let base_block = base.block_content_handle; + let base_style = base.style_handle; + + let mut extra_roots: Vec = Vec::new(); + let mut merged_handles = Vec::new(); + for &h in &handles[1..] { + if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity(h) { + if ml.content_type == acadrust::entities::LeaderContentType::Block + && ml.block_content_handle == base_block + && ml.style_handle == base_style + { + let shift_x = px - ml.context.content_base_point.x; + let shift_y = py - ml.context.content_base_point.y; + extra_roots.extend(ml.context.leader_roots.iter().cloned().map(|mut root| { + root.connection_point.x += shift_x; + root.connection_point.y += shift_y; + root + })); + merged_handles.push(h); + } + } + } + if merged_handles.is_empty() { + return false; + } + + if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity_mut(handles[0]) { + let shift_x = px - ml.context.content_base_point.x; + let shift_y = py - ml.context.content_base_point.y; + for root in &mut ml.context.leader_roots { + root.connection_point.x += shift_x; + root.connection_point.y += shift_y; + } + ml.context.content_base_point.x = px; + ml.context.content_base_point.y = py; + ml.context.block_content_location.x = px; + ml.context.block_content_location.y = py; + ml.context.text_location.x = px; + ml.context.text_location.y = py; + for root in extra_roots { + ml.context.leader_roots.push(root); + } + } + + scene.erase_entities(&merged_handles); + scene.bump_entities(&[(handles[0], crate::scene::ChangeKind::Modified)]); + true +} + + diff --git a/src/app/command_driver/modify.rs b/src/app/command_driver/modify.rs new file mode 100644 index 000000000..7a0393c9b --- /dev/null +++ b/src/app/command_driver/modify.rs @@ -0,0 +1,951 @@ +use super::*; + +impl OpenCADStudio { + pub(super) fn handle_lengthen_entity(&mut self, handle: Handle, pick_pt: glam::DVec3, mode: crate::modules::draw::modify::lengthen::LenMode) -> Option> { + let i = self.active_tab; + if self.reject_locked_edit(i, handle) { + return Some(Task::none()); + } + use crate::modules::draw::modify::lengthen::lengthen_entity; + let result = self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(|e| lengthen_entity(e, pick_pt.as_vec3(), &mode)); + match result { + Some(new_entity) => { + let label = self.history_label_from_active_cmd(i, "LENGTHEN"); + self.push_undo_snapshot(i, label); + self.tabs[i].scene.erase_entities(&[handle]); + self.tabs[i].scene.add_entity(new_entity); + self.tabs[i].dirty = true; + self.command_line + .push_output(crate::t!("LENGTHEN: applied.").as_ref()); + self.refresh_properties(); + } + None => { + self.command_line + .push_error(crate::t!("LENGTHEN: entity type not supported.").as_ref()); + } + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + None + } + + pub(super) fn handle_divide_entity(&mut self, handle: Handle, n: usize, marker: Option) { + let i = self.active_tab; + use crate::modules::draw::inquiry::divide::divide_entity; + let pts = self.tabs[i] + .scene + .document + .get_entity(handle) + .map(|e| divide_entity(e, n, marker.as_ref())) + .unwrap_or_default(); + let count = pts.len(); + if count > 0 { + self.push_undo_snapshot(i, "DIVIDE"); + let layer = self.tabs[i].active_layer.clone(); + for mut p in pts { + p.as_entity_mut().set_layer(layer.clone()); + self.tabs[i].scene.add_entity(p); + } + self.tabs[i].dirty = true; + self.command_line + .push_output(crate::tf!("DIVIDE: {count} marker(s) placed.").as_ref()); + } else { + self.command_line.push_error( + crate::t!("DIVIDE: entity type not supported or N < 2.").as_ref(), + ); + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + } + + pub(super) fn handle_measure_entity(&mut self, result: CmdResult) { + let i = self.active_tab; + let CmdResult::MeasureEntity { + handle, + segment_length, + pick_point, + marker, + } = result + else { + unreachable!("router only routes the matching variant"); + }; + use crate::modules::draw::inquiry::divide::measure_entity; + let pts = self.tabs[i] + .scene + .document + .get_entity(handle) + .map(|e| measure_entity(e, segment_length, pick_point, marker.as_ref())) + .unwrap_or_default(); + let count = pts.len(); + if count > 0 { + self.push_undo_snapshot(i, "MEASURE"); + let layer = self.tabs[i].active_layer.clone(); + for mut p in pts { + p.as_entity_mut().set_layer(layer.clone()); + self.tabs[i].scene.add_entity(p); + } + self.tabs[i].dirty = true; + self.command_line + .push_output(crate::tf!("MEASURE: {count} marker(s) placed.").as_ref()); + } else { + self.command_line + .push_output(crate::t!("MEASURE: 0 markers placed.").as_ref()); + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + } + + pub(super) fn handle_pedit_op(&mut self, handle: Handle, op: crate::modules::draw::modify::pedit::PeditOp) -> Task { + let i = self.active_tab; + self.apply_pedit_result(i, handle, op) + } + + pub(super) fn handle_join_to_source(&mut self, source: Handle, handles: Vec) -> Option> { + let i = self.active_tab; + if self.reject_locked_edit(i, source) { + return Some(Task::none()); + } + let joined = self.tabs[i] + .scene + .document + .get_entity(source) + .and_then(|entity| { + let candidates: Vec<_> = handles + .iter() + .filter(|handle| { + **handle != source && !self.tabs[i].scene.is_layer_locked(**handle) + }) + .filter_map(|handle| { + self.tabs[i] + .scene + .document + .get_entity(*handle) + .map(|entity| (*handle, entity)) + }) + .collect(); + crate::modules::draw::modify::join::join_to_source(entity, &candidates) + }); + if let Some((replacement, consumed)) = joined { + self.push_undo_snapshot(i, "JOIN"); + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(source) { + *entity = replacement; + } + self.tabs[i].scene.erase_entities(&consumed); + self.tabs[i].scene.refresh_fill_model(source); + self.tabs[i] + .scene + .bump_entities(&[(source, crate::scene::ChangeKind::Modified)]); + self.tabs[i].dirty = true; + self.command_line.push_output(&format!( + "JOIN: {} objects joined to source.", + consumed.len() + )); + self.refresh_properties(); + } else { + self.command_line + .push_info("JOIN: no compatible objects joined to source."); + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + None + } + + pub(super) fn handle_join_entities(&mut self, handles: Vec) -> Option> { + let i = self.active_tab; + if let Some(handle) = handles + .iter() + .find(|handle| self.tabs[i].scene.is_layer_locked(**handle)) + { + self.reject_locked_edit(i, *handle); + return Some(Task::none()); + } + use crate::modules::draw::modify::join::join_entities; + let pairs: Vec<_> = handles + .iter() + .filter_map(|&h| self.tabs[i].scene.document.get_entity(h).map(|e| (h, e))) + .collect(); + match join_entities(&pairs) { + Some((to_remove, merged)) => { + let label = self.history_label_from_active_cmd(i, "JOIN"); + self.push_undo_snapshot(i, label); + self.tabs[i].scene.erase_entities(&to_remove); + let count_in = to_remove.len(); + let count_out = merged.len(); + for e in merged { + self.tabs[i].scene.add_entity(e); + } + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + self.command_line.push_output( + crate::tf!("JOIN: {count_in} object(s) joined into {count_out}.") + .as_ref(), + ); + self.refresh_properties(); + } + None => { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + self.command_line.push_error( + crate::t!("JOIN: objects don't form a single connected chain, or contain an unsupported type / tilted arc.").as_ref(), + ); + } + } + None + } + + pub(super) fn handle_break_entity(&mut self, handle: Handle, p1: glam::DVec3, p2: glam::DVec3) -> Option> { + let i = self.active_tab; + if self.reject_locked_edit(i, handle) { + return Some(Task::none()); + } + use crate::modules::draw::modify::break_cmd::break_entity; + let replacement = self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(|e| break_entity(e, p1, p2)); + match replacement { + Some(frags) => { + let unchanged = frags.len() == 1 + && self.tabs[i].scene.document.get_entity(handle).is_some_and( + |original| { + let mut fragment = frags[0].clone(); + fragment.common_mut().handle = handle; + &fragment == original + }, + ); + if unchanged { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + self.command_line.push_output("BREAK: no geometry changed."); + return Some(Task::none()); + } + let label = self.history_label_from_active_cmd(i, "BREAK"); + self.push_undo_snapshot(i, label); + let count = frags.len(); + let owner = self.tabs[i] + .scene + .document + .get_entity(handle) + .map(|entity| entity.common().owner_handle); + let mut fragments = frags.into_iter(); + if let Some(mut first) = fragments.next() { + first.common_mut().handle = handle; + self.tabs[i].scene.update_entity(first); + for mut fragment in fragments { + fragment.common_mut().handle = Handle::NULL; + if let Some(owner) = owner { + fragment.common_mut().owner_handle = owner; + } + self.tabs[i].scene.add_entity(fragment); + } + } else { + self.tabs[i].scene.erase_entities(&[handle]); + } + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + self.command_line + .push_output(crate::tf!("BREAK: {} fragment(s).", count).as_ref()); + self.refresh_properties(); + } + None => { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + self.command_line + .push_error(crate::t!("BREAK: entity type not supported.").as_ref()); + } + } + None + } + + + pub(super) fn handle_hatchedit_apply(&mut self, result: CmdResult) -> Option> { + let i = self.active_tab; + let CmdResult::HatcheditApply { + handle, + name, + scale, + angle, + operation, + } = result + else { + unreachable!("router only routes the matching variant"); + }; + if self.reject_locked_edit(i, handle) { + return Some(Task::none()); + } + if !matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(acadrust::EntityType::Hatch(_)) + ) { + self.command_line + .push_error(crate::t!("HATCHEDIT: hatch entity not found.").as_ref()); + } else { + use crate::command::{CadCommand, HatchEditOperation}; + if matches!(&operation, HatchEditOperation::BeginAssociate) { + let associative = matches!(self.tabs[i].scene.document.get_entity(handle),Some(acadrust::EntityType::Hatch(h)) if h.is_associative); + if associative { + self.command_line + .push_info("HATCHEDIT: hatch is already associative."); + self.tabs[i].active_cmd = None; + } else { + let plane = match self.tabs[i].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Hatch(h)) => { + let storage = + crate::entities::curve::ocs_plane(h.normal, h.elevation); + crate::command::WorkingPlane::new( + glam::DVec3::from_array(storage.origin), + glam::DVec3::from_array(storage.x_axis), + glam::DVec3::from_array(storage.y_axis), + ) + } + _ => self.tabs[i].ucs_xform().working_plane(), + }; + let mut sources = + self.tabs[i].scene.boundary_sources_on_plane(plane, 1e-6); + sources.remove(&handle); + let command=crate::modules::draw::draw::hatchedit::HatcheditCommand::for_association(handle,name,scale,angle,plane,sources); + self.tabs[i].scene.deselect_all(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + return Some(Task::none()); + } + if let HatchEditOperation::DrawOrderBoundary { above } = &operation { + let references: Vec<_> = + match self.tabs[i].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Hatch(h)) => h + .paths + .iter() + .flat_map(|p| p.boundary_handles.iter()) + .filter(|h| { + self.tabs[i].scene.document.get_entity(**h).is_some() + }) + .map(|h| format!("{:X}", h.value())) + .collect(), + _ => Vec::new(), + }; + self.tabs[i].active_cmd = None; + if references.is_empty() { + self.command_line + .push_info("HATCHEDIT: no associated boundary objects."); + return Some(Task::none()); + } + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.select_entity(handle, false); + let command = format!( + "DRAWORDER {} {}", + if *above { "ABOVE" } else { "UNDER" }, + references.join(" ") + ); + return Some(self.dispatch_view(&command, i).unwrap_or_else(Task::none)); + } + if matches!( + &operation, + HatchEditOperation::DrawOrderFront | HatchEditOperation::DrawOrderBack + ) { + let command = if matches!(&operation, HatchEditOperation::DrawOrderFront) { + "DRAWORDER FRONT" + } else { + "DRAWORDER BACK" + }; + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.select_entity(handle, false); + self.tabs[i].active_cmd = None; + return Some(self.dispatch_view(command, i).unwrap_or_else(Task::none)); + } + self.push_undo_snapshot(i, "HATCHEDIT"); + match operation { + HatchEditOperation::Appearance { + color, + layer, + transparency, + } => { + let layer = layer.map(|name| { + if name == "." { + self.tabs[i].active_layer.clone() + } else { + name + } + }); + if layer.as_ref().is_some_and(|name| { + self.tabs[i].scene.document.layers.get(name).is_none() + }) { + self.discard_last_undo_entry(i); + self.command_line.push_error("HATCHEDIT: layer not found."); + return Some(Task::none()); + } + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) + { + let common = entity.common_mut(); + if let Some(value) = color { + common.color = value; + common.color_name = None; + common.color_book_handle = None; + } + if let Some(value) = layer { + common.layer = value; + } + if let Some(value) = transparency { + common.transparency = value; + } + } + self.tabs[i] + .scene + .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); + self.refresh_properties(); + } + HatchEditOperation::Update { + origin, + store_origin, + disassociate, + style, + annotative, + } => { + if store_origin { + if let Some((x, y)) = origin { + if !self.tabs[i].scene.document.set_hatch_origin([x, y]) { + self.discard_last_undo_entry(i); + self.command_line + .push_error("HATCHEDIT: cannot store default origin."); + return Some(Task::none()); + } + } + } + if let Some(acadrust::EntityType::Hatch(hatch)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + if !name.is_empty() && name != hatch.pattern.name { + if let Some(entry) = + crate::scene::model::hatch_patterns::find(&name) + { + let mut pattern = + crate::scene::model::hatch_patterns::build_dxf_pattern( + entry, + ); + crate::entities::hatch::scale_pattern_geometry( + &mut pattern, + scale.max(1.0e-6) as f64, + ); + crate::entities::hatch::rotate_pattern_geometry( + &mut pattern, + (angle as f64).to_radians(), + ); + let origin = hatch.pattern_origin(); + crate::entities::hatch::translate_pattern_geometry( + &mut pattern, + origin.x, + origin.y, + ); + hatch.pattern = pattern; + hatch.is_solid = matches!( + entry.gpu, + crate::scene::model::hatch_model::HatchPattern::Solid + ); + hatch.pattern_type = + acadrust::entities::HatchPatternType::Predefined; + hatch.gradient_color.enabled = false; + } + } else { + let requested_scale = scale.max(1.0e-6) as f64; + if hatch.pattern_scale > 1.0e-12 { + let factor = requested_scale / hatch.pattern_scale; + hatch.scale_pattern_about_origin(factor); + } + let requested_angle = (angle as f64).to_radians(); + let delta = requested_angle - hatch.pattern_angle; + hatch.rotate_pattern_about_origin(delta); + } + hatch.pattern_scale = scale.max(1.0e-6) as f64; + hatch.pattern_angle = (angle as f64).to_radians(); + if let Some((x, y)) = origin { + hatch.set_pattern_origin(acadrust::types::Vector2::new(x, y)); + } + if disassociate { + for path in &mut hatch.paths { + path.boundary_handles.clear(); + path.flags.set_external(false); + } + hatch.is_associative = false; + } + if let Some(style) = style { + hatch.style = style; + } + } + if let Some(value) = annotative { + crate::scene::annotative::set_entity_annotative( + &mut self.tabs[i].scene.document, + handle, + value, + ); + if value { + if let Some(scale_handle) = + self.tabs[i].scene.creation_annotation_scale_handle() + { + crate::scene::annotative::create_annotation_context( + &mut self.tabs[i].scene.document, + handle, + scale_handle, + ); + } + } + } + self.tabs[i] + .scene + .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); + } + HatchEditOperation::AddBoundaries(handles) => { + self.tabs[i] + .scene + .edit_hatch_boundary_handles(handle, &handles, true); + } + HatchEditOperation::RemoveBoundaries(handles) => { + self.tabs[i] + .scene + .edit_hatch_boundary_handles(handle, &handles, false); + } + HatchEditOperation::AssociatePaths(paths) => { + if paths.is_empty() + || paths.iter().any(|path| { + path.boundary_handles.is_empty() + || path.boundary_handles.iter().any(|source| { + self.tabs[i] + .scene + .document + .get_entity(*source) + .is_none() + }) + }) + { + self.discard_last_undo_entry(i); + self.command_line.push_error( + "HATCHEDIT: associated boundary is no longer available.", + ); + return Some(Task::none()); + } + self.tabs[i].scene.replace_hatch_association(handle, paths); + } + HatchEditOperation::RecreateBoundary { associate, region } => { + let source = self.tabs[i].scene.document.get_entity(handle).cloned(); + if let Some(acadrust::EntityType::Hatch(source)) = source { + let storage = crate::entities::curve::ocs_plane( + source.normal, + source.elevation, + ); + let plane = crate::command::WorkingPlane::new( + glam::DVec3::from_array(storage.origin), + glam::DVec3::from_array(storage.x_axis), + glam::DVec3::from_array(storage.y_axis), + ); + let (entities, path_groups) = if region { + let loops = source + .paths + .iter() + .map(|path| { + path.edges + .iter() + .map(crate::entities::hatch::edge_curve) + .collect::>>() + }) + .collect::>>(); + let Some(loops) = loops.filter(|loops| { + !loops.is_empty() + && loops.iter().all(|ring| !ring.is_empty()) + }) else { + self.discard_last_undo_entry(i); + self.command_line.push_error( + "HATCHEDIT: boundary cannot form a region.", + ); + return Some(Task::none()); + }; + let contained = loops + .iter() + .enumerate() + .map(|(inner, ring)| { + let seed = ring[0].point_at(0.0); + loops + .iter() + .enumerate() + .map(|(outer, boundary)| { + inner != outer + && cadkernel::geom2d::contains( + boundary, + seed, + cadkernel::geom2d::Tolerance::new(1e-6), + ) + }) + .collect::>() + }) + .collect::>(); + let depths = contained + .iter() + .map(|row| row.iter().filter(|inside| **inside).count()) + .collect::>(); + let groups = depths + .iter() + .enumerate() + .filter(|(_, depth)| **depth % 2 == 0) + .map(|(outer, depth)| { + let mut indices = vec![outer]; + indices.extend((0..loops.len()).filter(|inner| { + depths[*inner] == *depth + 1 + && contained[*inner][outer] + })); + indices + }) + .collect::>(); + let entities = groups + .iter() + .map(|indices| { + let profiles = indices + .iter() + .map(|index| loops[*index].clone()) + .collect::>(); + crate::scene::model::presspull_model::region_from_loops( + &profiles, plane, + ) + }) + .collect::>>(); + let Some(entities) = entities else { + self.discard_last_undo_entry(i); + self.command_line.push_error( + "HATCHEDIT: boundary cannot form a region.", + ); + return Some(Task::none()); + }; + (entities, groups) + } else { + let rings = crate::scene::hatch_boundary_rings(&source); + if rings.len() != source.paths.len() { + self.discard_last_undo_entry(i); + self.command_line.push_error( + "HATCHEDIT: boundary cannot form a polyline.", + ); + return Some(Task::none()); + } + let entities = crate::scene::boundary_entities(&rings, plane); + if entities.len() != source.paths.len() { + self.discard_last_undo_entry(i); + self.command_line.push_error( + "HATCHEDIT: boundary cannot form a polyline.", + ); + return Some(Task::none()); + } + let groups = + (0..entities.len()).map(|index| vec![index]).collect(); + (entities, groups) + }; + let mut path_handles = vec![None; source.paths.len()]; + for (entity, paths) in entities.into_iter().zip(path_groups) { + if let Some(boundary) = self.commit_entity_handle(entity) { + for path in paths { + if let Some(slot) = path_handles.get_mut(path) { + *slot = Some(boundary); + } + } + } + } + if associate { + if let Some(acadrust::EntityType::Hatch(hatch)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + for (path, boundary) in + hatch.paths.iter_mut().zip(path_handles.iter().copied()) + { + if let Some(boundary) = boundary { + path.boundary_handles = vec![boundary]; + path.flags.set_external(true); + } + } + hatch.is_associative = + path_handles.iter().all(Option::is_some); + } + } + self.tabs[i] + .scene + .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); + } + } + HatchEditOperation::Separate => { + let source = self.tabs[i].scene.document.get_entity(handle).cloned(); + if let Some(acadrust::EntityType::Hatch(hatch)) = source { + let groups = crate::scene::separated_hatch_path_groups(&hatch); + if groups.len() > 1 { + for paths in groups { + let mut separated = hatch.clone(); + separated.common.handle = acadrust::Handle::NULL; + separated.paths = paths; + separated.is_associative = separated + .paths + .iter() + .any(|path| !path.boundary_handles.is_empty()); + self.tabs[i] + .scene + .add_entity(acadrust::EntityType::Hatch(separated)); + } + self.tabs[i].scene.erase_entities(&[handle]); + } + } + } + HatchEditOperation::DrawOrderFront + | HatchEditOperation::DrawOrderBack + | HatchEditOperation::DrawOrderBoundary { .. } + | HatchEditOperation::BeginAssociate => unreachable!(), + } + self.tabs[i].dirty = true; + self.command_line + .push_output(crate::t!("HATCHEDIT: hatch updated.").as_ref()); + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + None + } + + fn apply_pedit_result( + &mut self, + tab: usize, + handle: Handle, + op: crate::modules::draw::modify::pedit::PeditOp, + ) -> Task { + use crate::modules::draw::modify::pedit::{apply_pedit, convert_to_polyline, PeditOp}; + + if !matches!( + &op, + PeditOp::Multiple(_, _) | PeditOp::JoinSelection(_, _, _) + ) && self.reject_locked_edit(tab, handle) + { + return Task::none(); + } + match &op { + PeditOp::JoinSelection(handles, fuzz, kind) => { + let mut available = handles + .iter() + .filter_map(|handle| { + if self.tabs[tab].scene.is_layer_locked(*handle) { + return None; + } + self.tabs[tab] + .scene + .document + .get_entity(*handle) + .cloned() + .map(|entity| (*handle, entity)) + }) + .collect::>(); + let connector_distance = + crate::modules::draw::modify::pedit::selection_connector_distance( + &available, *fuzz, + ); + let mut changes = Vec::new(); + while !available.is_empty() { + let (source_handle, source) = available.remove(0); + let candidates = available + .iter() + .map(|(handle, entity)| (*handle, entity)) + .collect::>(); + if let Some((mut result, consumed)) = + crate::modules::draw::modify::pedit::join_selection_extend( + &source, + &candidates, + *fuzz, + *kind, + connector_distance, + ) + { + *result.common_mut() = source.common().clone(); + available.retain(|(handle, _)| !consumed.contains(handle)); + changes.push((source_handle, result, consumed)); + } + } + if !changes.is_empty() { + self.push_undo_snapshot(tab, "PEDIT"); + for (source, replacement, consumed) in changes { + self.tabs[tab].scene.erase_entities(&consumed); + self.tabs[tab].scene.update_entity(replacement.clone()); + if let Some(command) = self.tabs[tab].active_cmd.as_mut() { + for old in consumed { + command.on_entity_replaced(old, &[source]); + } + command.inject_picked_entity(replacement); + } + } + if let Some(command) = self.tabs[tab].active_cmd.as_mut() { + command.on_pedit_applied(); + } + self.tabs[tab].dirty = true; + self.refresh_properties(); + } + } + PeditOp::Multiple(handles, operation) => { + let replacements: Vec<_> = handles + .iter() + .filter(|handle| !self.tabs[tab].scene.is_layer_locked(**handle)) + .filter_map(|handle| { + let original = self.tabs[tab].scene.document.get_entity(*handle)?; + if matches!(operation.as_ref(), PeditOp::ConvertToPolyline) { + convert_to_polyline(original) + .map(|replacement| (*handle, replacement, true)) + } else { + let mut replacement = original.clone(); + apply_pedit(&mut replacement, operation).then_some(( + *handle, + replacement, + false, + )) + } + }) + .collect(); + if replacements.is_empty() { + return Task::none(); + } + self.push_undo_snapshot(tab, "PEDIT"); + for (handle, replacement, converted) in replacements { + let updated_handle = if converted { + self.tabs[tab].scene.erase_entities(&[handle]); + let new_handle = self.tabs[tab].scene.add_entity(replacement); + if let Some(command) = self.tabs[tab].active_cmd.as_mut() { + command.on_entity_replaced(handle, &[new_handle]); + } + new_handle + } else { + if let Some(entity) = self.tabs[tab].scene.document.get_entity_mut(handle) { + *entity = replacement; + } + self.tabs[tab].scene.refresh_fill_model(handle); + self.tabs[tab] + .scene + .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); + handle + }; + let updated = self.tabs[tab] + .scene + .document + .get_entity(updated_handle) + .cloned(); + if let (Some(command), Some(entity)) = + (self.tabs[tab].active_cmd.as_mut(), updated) + { + command.inject_picked_entity(entity); + } + } + if let Some(command) = self.tabs[tab].active_cmd.as_mut() { + command.on_pedit_applied(); + } + self.tabs[tab].dirty = true; + self.refresh_properties(); + } + PeditOp::VertexRange { first, last, split } => { + let pieces = self.tabs[tab] + .scene + .document + .get_entity(handle) + .and_then(|entity| { + crate::modules::draw::modify::pedit::edit_vertex_range( + entity, *first, *last, *split, + ) + }); + if let Some(mut pieces) = pieces { + self.push_undo_snapshot(tab, "PEDIT"); + let source = pieces.remove(0); + self.tabs[tab].scene.update_entity(source); + for mut piece in pieces { + piece.common_mut().handle = Handle::NULL; + self.tabs[tab].scene.add_entity(piece); + } + let updated = self.tabs[tab].scene.document.get_entity(handle).cloned(); + if let Some(command) = self.tabs[tab].active_cmd.as_mut() { + if let Some(entity) = updated { + command.inject_picked_entity(entity); + } + command.on_pedit_applied(); + } + self.tabs[tab].dirty = true; + self.refresh_properties(); + } else { + self.command_line.push_error("PEDIT: invalid vertex range."); + } + } + PeditOp::ConvertToPolyline => { + let converted = self.tabs[tab] + .scene + .document + .get_entity(handle) + .and_then(convert_to_polyline); + match converted { + Some(polyline) => { + return self + .apply_cmd_result(CmdResult::ReplaceEntity(handle, vec![polyline])); + } + None => self + .command_line + .push_error(crate::t!("PEDIT: cannot convert this entity.").as_ref()), + } + } + _ => { + self.push_undo_snapshot(tab, "PEDIT"); + let changed = self.tabs[tab] + .scene + .document + .get_entity_mut(handle) + .map(|entity| apply_pedit(entity, &op)) + .unwrap_or(false); + if changed { + let updated = self.tabs[tab].scene.document.get_entity(handle).cloned(); + if let Some(command) = self.tabs[tab].active_cmd.as_mut() { + if let Some(entity) = updated { + command.inject_picked_entity(entity); + } + command.on_pedit_applied(); + } + self.tabs[tab].dirty = true; + self.tabs[tab].scene.refresh_fill_model(handle); + self.tabs[tab] + .scene + .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); + self.command_line + .push_output(crate::t!("PEDIT: applied.").as_ref()); + self.refresh_properties(); + } else { + self.discard_last_undo_entry(tab); + self.command_line.push_error( + crate::t!("PEDIT: operation not applicable to this entity.").as_ref(), + ); + } + } + } + if let Some(prompt) = self.tabs[tab] + .active_cmd + .as_ref() + .map(|command| command.prompt()) + { + self.command_line.push_info(&prompt); + } + Task::none() + } +} diff --git a/src/app/command_driver/parameters.rs b/src/app/command_driver/parameters.rs new file mode 100644 index 000000000..584d3e02f --- /dev/null +++ b/src/app/command_driver/parameters.rs @@ -0,0 +1,992 @@ +use super::*; + +/// `old` as a whole identifier in `source` becomes `new`. +fn replace_identifier(source: &str, old: &str, new: &str) -> String { + let mut out = String::with_capacity(source.len()); + let mut token = String::new(); + let flush = |token: &mut String, out: &mut String| { + if !token.is_empty() { + out.push_str(if token == old { new } else { token.as_str() }); + token.clear(); + } + }; + for c in source.chars() { + if c.is_alphanumeric() || c == '_' { + token.push(c); + } else { + flush(&mut token, &mut out); + out.push(c); + } + } + flush(&mut token, &mut out); + out +} + +impl OpenCADStudio { + /// Drops what removed dimensional constraints leave behind: their dynamic + /// dimensions, and their parameters when no other constraint reads them. + pub(crate) fn purge_dimensional_extras( + &mut self, + i: usize, + dimensions: Vec, + parameters: Vec, + ) { + if !dimensions.is_empty() { + self.tabs[i].scene.erase_entities(&dimensions); + } + let unused: Vec = parameters + .into_iter() + .filter(|name| { + !self.tabs[i] + .scene + .parametric_constraints + .iter() + .flat_map(|set| set.constraints.iter()) + .any(|constraint| { + matches!( + &constraint.driving_param, + Some(crate::scene::named_parameters::DrivingValue::Named(used)) + if used == name + ) + }) + }) + .collect(); + if !unused.is_empty() { + self.tabs[i].scene.record_undo_named_parameters_before(); + for name in &unused { + self.tabs[i].scene.named_parameters_mut().remove(name); + } + } + } + + /// `Dynamic` or `Annotational`, the DCFORM setting. + pub(crate) fn constraint_form_name(&self) -> &'static str { + if self.constraint_form_annotational { + "Annotational" + } else { + "Dynamic" + } + } + + /// The value prompt a double-clicked constraint dimension opens. + pub(crate) fn dynamic_dimension_value_command( + &self, + i: usize, + handle: Handle, + ) -> Option { + use crate::scene::named_parameters::DrivingValue; + let scene = &self.tabs[i].scene; + for set in &scene.parametric_constraints { + let Some((id, _)) = set + .dimensions + .iter() + .find(|(_, dimension)| **dimension == handle) + else { + continue; + }; + let constraint = set.get(*id)?; + let Some(DrivingValue::Named(name)) = &constraint.driving_param else { + return None; + }; + let table = if set.local_parameters.is_empty() { + scene.named_parameters() + } else { + &set.local_parameters + }; + let current = table + .get(name) + .map(|parameter| parameter.source.trim().to_string()) + .unwrap_or_default(); + return Some(crate::modules::parametric::DimensionValueCommand::new( + name.clone(), + current, + )); + } + None + } + + /// Applies `expression` or `newname=expression` to parameter `name`: + /// the dimension value prompt and -PARAMETERS Edit. + pub(crate) fn apply_parameter_input(&mut self, i: usize, name: &str, input: &str) { + let input = input.trim(); + let (target, expression) = match input.split_once('=') { + Some((new_name, expression)) => { + (new_name.trim().to_string(), expression.trim().to_string()) + } + None => (name.to_string(), input.to_string()), + }; + if expression.is_empty() { + return; + } + if target != name { + if let Err(error) = self.rename_parameter(i, name, &target) { + self.command_line.push_error(&error); + return; + } + } + let mut table = self.tabs[i].scene.named_parameters().clone(); + if let Err(error) = table.set(&target, &expression) { + self.command_line.push_error(&error.to_string()); + return; + } + if !table + .resolve(&target) + .is_ok_and(|value| value.is_finite()) + { + self.command_line.push_error("Invalid expression."); + return; + } + let pending = self.begin_undo(i, "Edit parameter", 0, true); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i].scene.named_parameters = table; + self.resolve_named_parameter_edit(i, &target); + self.tabs[i].scene.refresh_dynamic_dimension_texts(); + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + + /// -PARAMETERS New. + pub(crate) fn create_parameter(&mut self, i: usize, name: &str, expression: &str) { + if self.tabs[i].scene.named_parameters().contains(name) { + self.command_line + .push_error(&format!("Parameter {name} already exists.")); + return; + } + let mut table = self.tabs[i].scene.named_parameters().clone(); + if let Err(error) = table.set(name, expression) { + self.command_line.push_error(&error.to_string()); + return; + } + let pending = self.begin_undo(i, "New parameter", 0, true); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i].scene.named_parameters = table; + self.tabs[i].scene.sync_native_parametric_graph(); + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + + /// Renames a parameter everywhere: the table, the expressions that use + /// it and the constraints it drives. + pub(crate) fn rename_parameter(&mut self, i: usize, old: &str, new: &str) -> Result<(), String> { + use crate::scene::named_parameters::{is_reserved_name, is_valid_name, DrivingValue}; + let table = self.tabs[i].scene.named_parameters().clone(); + let Some(source) = table.get(old).map(|parameter| parameter.source.clone()) else { + return Err(format!("Parameter {old} not found.")); + }; + if !is_valid_name(new) || is_reserved_name(new) || table.contains(new) { + return Err(format!("Invalid parameter name {new}.")); + } + let mut renamed = table.clone(); + renamed.set(new, &source).map_err(|error| error.to_string())?; + for parameter in table.iter() { + if parameter.name == old { + continue; + } + let rewritten = replace_identifier(¶meter.source, old, new); + if rewritten != parameter.source { + renamed + .set(¶meter.name, &rewritten) + .map_err(|error| error.to_string())?; + } + } + renamed.remove(old); + let pending = self.begin_undo(i, "Rename parameter", 0, true); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i].scene.named_parameters = renamed; + let scopes: Vec<_> = self.tabs[i] + .scene + .parametric_constraints + .iter() + .map(|set| set.scope) + .collect(); + for scope in scopes { + let before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .filter(|set| { + set.constraints.iter().any(|constraint| { + matches!(&constraint.driving_param, Some(DrivingValue::Named(name)) if name == old) + }) + }) + .cloned(); + let Some(before) = before else { + continue; + }; + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, before); + let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); + for constraint in &mut set.constraints { + if matches!(&constraint.driving_param, Some(DrivingValue::Named(name)) if name == old) { + constraint.driving_param = Some(DrivingValue::Named(new.to_string())); + } + } + } + self.tabs[i].scene.refresh_dynamic_dimension_texts(); + self.tabs[i].scene.sync_native_parametric_graph(); + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + Ok(()) + } + + /// -PARAMETERS Delete: a parameter no constraint drives with. + pub(crate) fn delete_parameter(&mut self, i: usize, name: &str) { + use crate::scene::named_parameters::DrivingValue; + if !self.tabs[i].scene.named_parameters().contains(name) { + self.command_line + .push_error(&format!("Parameter {name} not found.")); + return; + } + let in_use = self.tabs[i] + .scene + .parametric_constraints + .iter() + .flat_map(|set| set.constraints.iter()) + .any(|constraint| { + matches!(&constraint.driving_param, Some(DrivingValue::Named(used)) if used == name) + }); + if in_use { + self.command_line + .push_error(&format!("Parameter {name} is used by a constraint.")); + return; + } + let pending = self.begin_undo(i, "Delete parameter", 0, true); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i].scene.named_parameters_mut().remove(name); + self.tabs[i].scene.sync_native_parametric_graph(); + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + + fn remove_parametric_constraint( + &mut self, + id: crate::scene::parametric_constraints::ConstraintId, + label: &'static str, + ) -> bool { + let i = self.active_tab; + let scope = self.tabs[i].current_parametric_scope(); + let Some(set) = self.tabs[i].scene.parametric_constraint_set(scope) else { + return false; + }; + let Some(constraint) = set.get(id) else { + return false; + }; + let touched: Vec = constraint.refs.iter().map(|r| r.entity).collect(); + let dimension = set.dimensions.get(&id).copied(); + let parameter = match &constraint.driving_param { + Some(crate::scene::named_parameters::DrivingValue::Named(name)) => Some(name.clone()), + _ => None, + }; + let constraints_before = set.clone(); + let pending = self.begin_undo(i, label, touched.len(), dimension.is_none()); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + self.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .remove(id); + self.purge_dimensional_extras( + i, + dimension.into_iter().collect(), + parameter.into_iter().collect(), + ); + self.tabs[i].scene.bump_constraints_epoch(); + if self.tabs[i].scene.selected_constraint == Some(id) { + self.tabs[i].scene.selected_constraint = None; + } + let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched + .into_iter() + .map(|h| (h, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + self.refresh_properties(); + true + } + + /// Removes and re-solves the first conflicting constraint in the active scope. + pub(in crate::app) fn resolve_one_parametric_conflict(&mut self) { + let i = self.active_tab; + let scope = self.tabs[i].current_parametric_scope(); + let Some(id) = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .and_then(|set| set.conflicts.first().map(|(id, _)| *id)) + else { + return; + }; + self.remove_parametric_constraint(id, "Remove conflicting constraint"); + } + + /// Removes one user-selected constraint from the active standard graph scope. + pub(in crate::app) fn delete_parametric_constraint( + &mut self, + id: crate::scene::parametric_constraints::ConstraintId, + ) { + self.remove_parametric_constraint(id, "Delete constraint"); + } + + /// Rebuilds the active tab's `Scene::named_parameters` from the + /// editor's working buffer and re-solves dependent constraints. + pub(in crate::app) fn apply_named_parameter_editor_rows(&mut self) { + let i = self.active_tab; + // A name shared by more than one row can't be resolved by picking + // one arbitrarily (`ParameterTable::set` would just let the last + // one silently win) -- refuse every row sharing that name up front, + // same as `named_parameters::preview`'s live check. + let duplicates = crate::ui::window::named_parameters::duplicate_name_rows( + &self.named_parameter_editor_rows, + ); + let mut table = crate::scene::named_parameters::ParameterTable::new(); + let mut failed = 0usize; + for (idx, row) in self.named_parameter_editor_rows.iter().enumerate() { + let name = row.name.trim(); + if name.is_empty() || duplicates.contains(&idx) { + continue; + } + if let Err(e) = table.set(name, row.formula.trim()) { + self.command_line + .push_error(crate::tf!("Parameter '{}': {}", name, e).as_ref()); + failed += 1; + } + } + if !duplicates.is_empty() { + self.command_line.push_error( + crate::tf!( + "{} row(s) skipped: duplicate parameter name.", + duplicates.len() + ) + .as_ref(), + ); + failed += duplicates.len(); + } + let param_count = table.len(); + + // The whole table just changed, not one isolated value, so there is + // no cheaper "which handles actually moved" test worth doing here — + // re-solve every scope with a constraint driven by *any* named + // parameter (mirrors `solve_scope`'s own "full rebuild, not + // incremental" philosophy). + let touched: Vec = self.tabs[i] + .scene + .parametric_constraints + .iter() + .flat_map(|set| set.constraints.iter()) + .filter(|c| { + c.enabled + && matches!( + c.driving_param, + Some(crate::scene::named_parameters::DrivingValue::Named(_)) + ) + }) + .flat_map(|c| c.refs.iter().map(|r| r.entity)) + .collect(); + + // A changed value moves a dimensional constraint's second point; its + // first point stays, as in the reference. + let anchors: Vec = self.tabs[i] + .scene + .parametric_constraints + .iter() + .flat_map(|set| set.constraints.iter()) + .filter(|c| { + c.enabled + && matches!( + c.driving_param, + Some(crate::scene::named_parameters::DrivingValue::Named(_)) + ) + }) + .flat_map(|c| { + crate::scene::parametric_constraints::dimensional_anchor_refs( + &self.tabs[i].scene.document, + &c.refs, + ) + }) + .collect(); + let pending = self.begin_undo(i, "Apply named parameters", touched.len(), true); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i].scene.named_parameters = table; + self.tabs[i].dirty = true; + if !touched.is_empty() { + let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched + .into_iter() + .map(|h| (h, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &anchors, + self.constraint_solve_mode, + ); + } else { + self.tabs[i].scene.sync_native_parametric_graph(); + } + self.tabs[i].scene.refresh_dynamic_dimension_texts(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + if failed == 0 { + self.command_line + .push_info(crate::tf!("{} parameter(s) applied.", param_count).as_ref()); + } + } + + /// Re-solves every entity touched by an enabled constraint driven by + /// `name` — the targeted, single-row counterpart to + /// `apply_named_parameter_editor_rows`'s "re-solve what it affects": an + /// inline Properties-panel edit changes one parameter, not the whole + /// table, so unlike that whole-table Apply this can cheaply scope the + /// re-solve to just the entities that actually reference it. + fn resolve_named_parameter_edit(&mut self, i: usize, name: &str) { + let readers: Vec<&crate::scene::parametric_constraints::ParametricConstraint> = self + .tabs[i] + .scene + .parametric_constraints + .iter() + .flat_map(|set| set.constraints.iter()) + .filter(|c| { + c.enabled + && matches!(&c.driving_param, Some(crate::scene::named_parameters::DrivingValue::Named(n)) if n == name) + }) + .collect(); + let touched: Vec = readers + .iter() + .flat_map(|c| c.refs.iter().map(|r| r.entity)) + .collect(); + // A changed value moves a dimensional constraint's second point; its + // first point (and the line it measures perpendicular to) stays, as + // in the reference. + let anchors: Vec = readers + .iter() + .flat_map(|c| { + crate::scene::parametric_constraints::dimensional_anchor_refs( + &self.tabs[i].scene.document, + &c.refs, + ) + }) + .collect(); + self.tabs[i].dirty = true; + if touched.is_empty() { + self.tabs[i].scene.sync_native_parametric_graph(); + return; + } + let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched + .into_iter() + .map(|h| (h, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i] + .scene + .bump_entities_with_parametric_policy(&changes, &anchors, false); + } + + /// Commits one field of one Parameters-section row (Properties panel) — + /// the per-row, commit-on-submit counterpart to + /// `apply_named_parameter_editor_rows`'s whole-table Apply. A Formula + /// edit is a plain redefine (`ParameterTable::set` on the existing + /// name); a Name edit is a remove-then-set (there's no rename primitive + /// — `ParameterTable` upserts by name), restoring the old name if the + /// new one fails to validate so nothing is silently lost. Either way + /// this only re-solves what actually depends on this one parameter — + /// no "duplicate name" scan across the whole table is needed the way + /// the buffered modal Apply needs one, since every row here is always + /// already a real, distinct table entry. + pub(in crate::app) fn on_prop_param_commit( + &mut self, + index: usize, + field: crate::ui::window::named_parameters::ParamField, + ) -> Task { + use crate::ui::properties::FieldKey; + use crate::ui::window::named_parameters::ParamField; + let i = self.active_tab; + let key = FieldKey::Param(index, field); + self.tabs[i].properties.active_field = None; + let Some(typed) = self.tabs[i].properties.edit_buf.remove(&key) else { + return Task::none(); + }; + let typed = typed.trim().to_string(); + let Some(current) = self.tabs[i] + .scene + .named_parameters() + .iter() + .nth(index) + .cloned() + else { + self.refresh_properties(); + return Task::none(); + }; + + match field { + ParamField::Formula if typed.is_empty() || typed == current.source => { + self.refresh_properties(); + return Task::none(); + } + ParamField::Name if typed.is_empty() || typed == current.name => { + self.refresh_properties(); + return Task::none(); + } + ParamField::Name if self.tabs[i].scene.named_parameters().contains(&typed) => { + self.command_line + .push_error(crate::tf!("Parameter '{}' already exists.", typed).as_ref()); + self.refresh_properties(); + return Task::none(); + } + _ => {} + } + + let pending = self.begin_undo(i, "Edit named parameter", 0, true); + self.tabs[i].scene.record_undo_named_parameters_before(); + + let (result, resolve_name) = match field { + ParamField::Formula => ( + self.tabs[i] + .scene + .named_parameters_mut() + .set(¤t.name, &typed), + current.name.clone(), + ), + ParamField::Name => { + let description = self.tabs[i] + .scene + .named_parameters() + .description(¤t.name) + .to_string(); + self.tabs[i] + .scene + .named_parameters_mut() + .remove(¤t.name); + let outcome = self.tabs[i] + .scene + .named_parameters_mut() + .set(&typed, ¤t.source); + let kept = if outcome.is_err() { + let _ = self.tabs[i] + .scene + .named_parameters_mut() + .set(¤t.name, ¤t.source); + current.name.as_str() + } else { + typed.as_str() + }; + self.tabs[i] + .scene + .named_parameters_mut() + .set_description(kept, &description); + (outcome, typed.clone()) + } + }; + + if let Err(e) = result { + self.tabs[i].scene.take_undo_recording(); + self.command_line + .push_error(crate::tf!("Parameter '{}': {}", current.name, e).as_ref()); + self.refresh_properties(); + return Task::none(); + } + + if field == ParamField::Name { + let affected: Vec = self.tabs[i] + .scene + .parametric_constraints + .iter() + .enumerate() + .filter_map(|(index, set)| { + set.constraints + .iter() + .any(|constraint| { + matches!(&constraint.driving_param, Some(crate::scene::named_parameters::DrivingValue::Named(name)) if name == ¤t.name) + }) + .then_some(index) + }) + .collect(); + for set_index in affected { + let scope = self.tabs[i].scene.parametric_constraints[set_index].scope; + let before = self.tabs[i].scene.parametric_constraints[set_index].clone(); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, before); + // A parameter rename rewrites the `Named` driving references + // pointing at it — the glyph labels change with them. + let mut renamed = false; + for constraint in + &mut self.tabs[i].scene.parametric_constraints[set_index].constraints + { + if let Some(crate::scene::named_parameters::DrivingValue::Named(name)) = + &mut constraint.driving_param + { + if name == ¤t.name { + *name = typed.clone(); + renamed = true; + } + } + } + if renamed { + self.tabs[i].scene.bump_constraints_epoch(); + } + } + } + self.resolve_named_parameter_edit(i, &resolve_name); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + self.refresh_properties(); + Task::none() + } + + /// The dynamic dimension the Properties panel targets: its handle, scope, + /// constraint id and parameter name. + fn dynamic_dimension_target( + &self, + i: usize, + ) -> Option<( + Handle, + crate::scene::parametric_constraints::ParametricScope, + crate::scene::parametric_constraints::ConstraintId, + String, + )> { + use crate::scene::named_parameters::DrivingValue; + use crate::scene::parametric_constraints::dynamic_dimension_constraint; + let handle = *self.property_target_handles(i).first()?; + let (set, constraint) = + dynamic_dimension_constraint(&self.tabs[i].scene.parametric_constraints, handle)?; + let Some(DrivingValue::Named(name)) = &constraint.driving_param else { + return None; + }; + Some((handle, set.scope, constraint.id, name.clone())) + } + + /// Commits a dynamic dimension's Description row into its parameter. + pub(in crate::app) fn on_dynamic_dimension_description_commit( + &mut self, + field: &'static str, + ) -> Task { + use crate::ui::properties::FieldKey; + let i = self.active_tab; + self.tabs[i].properties.active_field = None; + let Some(typed) = self.tabs[i] + .properties + .edit_buf + .remove(&FieldKey::Geom(field)) + else { + return Task::none(); + }; + let Some((_, _, _, name)) = self.dynamic_dimension_target(i) else { + self.refresh_properties(); + return Task::none(); + }; + let pending = self.begin_undo(i, "Constraint description", 0, true); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i] + .scene + .named_parameters_mut() + .set_description(&name, &typed); + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + self.refresh_properties(); + Task::none() + } + + /// Applies a dynamic dimension's Constraint Form or Reference choice. + pub(in crate::app) fn on_dynamic_dimension_choice( + &mut self, + field: &'static str, + value: &str, + ) -> Task { + use crate::scene::parametric_constraints::DYNAMIC_DIMENSION_LAYER; + let i = self.active_tab; + let Some((handle, scope, id, _)) = self.dynamic_dimension_target(i) else { + return Task::none(); + }; + match field { + "dyn_constraint_form" => { + // Annotational: an ordinary dimension on the current layer + // that plots; Dynamic: the gray one on the constraints layer. + let annotational = value.eq_ignore_ascii_case("Annotational"); + let active_layer = self.tabs[i].active_layer.clone(); + if !annotational { + self.tabs[i].scene.ensure_dynamic_dimension_layer(); + } + self.apply_property_op(i, "Constraint form", &[handle], |app, handle| { + use crate::entities::dim_override as dov; + let Some(mut entity) = app.tabs[i].scene.document.get_entity(handle).cloned() + else { + return; + }; + if annotational { + entity.as_entity_mut().set_layer(active_layer.clone()); + entity.common_mut().color = acadrust::types::Color::ByLayer; + // The dynamic form's screen-size and horizontal-text + // overrides go; the style draws it. + for code in [ + dov::DIMSCALE, + dov::DIMGAP, + dov::DIMEXO, + dov::DIMEXE, + dov::DIMASZ, + dov::DIMTAD, + dov::DIMTIH, + dov::DIMTOH, + ] { + dov::set_on_entity(&mut entity, code, None); + } + } else { + entity + .as_entity_mut() + .set_layer(DYNAMIC_DIMENSION_LAYER.to_string()); + entity.common_mut().color = acadrust::types::Color::Rgb { + r: 103, + g: 109, + b: 118, + }; + } + app.tabs[i].scene.update_entity(entity); + }); + // The dynamic form is rescaled to the screen and shows the + // trimmed value; the annotational one is never hidden. + self.tabs[i].scene.refresh_dynamic_dimension_scales(true); + self.tabs[i].scene.refresh_dynamic_dimension_texts(); + self.tabs[i].scene.refresh_hidden_dynamic_dimensions(); + } + "dyn_constraint_reference" => { + // A reference constraint reads the geometry instead of + // driving it; its text sits in parentheses. + let reference = + value.eq_ignore_ascii_case("Yes") || value == crate::t!("Yes").as_ref(); + let Some(before) = self.tabs[i].scene.parametric_constraint_set(scope).cloned() + else { + return Task::none(); + }; + let pending = self.begin_undo(i, "Constraint reference", 0, true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, before); + self.tabs[i].scene.record_undo_named_parameters_before(); + let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); + if let Some(constraint) = set.constraints.iter_mut().find(|c| c.id == id) { + constraint.enabled = !reference; + } + // Toggling `enabled` changes the glyph placement set (and the + // dynamic pills), so the memoised placements must miss next + // frame — same guarantee `set_constraint_enabled` gives. + self.tabs[i].scene.bump_constraints_epoch(); + self.tabs[i].scene.refresh_dynamic_dimension_texts(); + self.tabs[i].scene.sync_native_parametric_graph(); + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + self.refresh_properties(); + } + _ => {} + } + Task::none() + } + + /// Commits a dynamic dimension's Name or Expression row: the typed text + /// goes to the Parameters-section row of the constraint's parameter. + pub(in crate::app) fn on_dynamic_dimension_field_commit( + &mut self, + field: &'static str, + param_field: crate::ui::window::named_parameters::ParamField, + ) -> Task { + use crate::scene::named_parameters::DrivingValue; + use crate::scene::parametric_constraints::dynamic_dimension_constraint; + use crate::ui::properties::FieldKey; + let i = self.active_tab; + self.tabs[i].properties.active_field = None; + let Some(typed) = self.tabs[i] + .properties + .edit_buf + .remove(&FieldKey::Geom(field)) + else { + return Task::none(); + }; + let handles = self.property_target_handles(i); + let name = handles.first().and_then(|handle| { + let (_, constraint) = + dynamic_dimension_constraint(&self.tabs[i].scene.parametric_constraints, *handle)?; + match &constraint.driving_param { + Some(DrivingValue::Named(name)) => Some(name.clone()), + _ => None, + } + }); + let index = name.and_then(|name| { + self.tabs[i] + .scene + .named_parameters() + .iter() + .position(|parameter| parameter.name == name) + }); + let Some(index) = index else { + self.refresh_properties(); + return Task::none(); + }; + self.tabs[i] + .properties + .edit_buf + .insert(FieldKey::Param(index, param_field), typed); + self.on_prop_param_commit(index, param_field) + } + + /// Removes Parameters-section row `index` immediately. Any + /// constraint that referenced it gets the same "undefined reference" + /// resolve-failure treatment a formula's own bad reference already gets + /// (`build_constraint`) — re-solving surfaces that rather than needing + /// special-case handling here. + pub(in crate::app) fn on_prop_param_delete(&mut self, index: usize) -> Task { + let i = self.active_tab; + let Some(name) = self.tabs[i] + .scene + .named_parameters() + .iter() + .nth(index) + .map(|p| p.name.clone()) + else { + return Task::none(); + }; + let pending = self.begin_undo(i, "Delete named parameter", 0, true); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i].scene.named_parameters_mut().remove(&name); + self.resolve_named_parameter_edit(i, &name); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + self.refresh_properties(); + Task::none() + } + + /// The Parameters section's "+ Add parameter" row: appends a fresh, + /// uniquely-named parameter (`param1`, `param2`, …) the user then + /// renames/redefines inline via `on_prop_param_commit`. Defaults to `1`, + /// not `0`: renaming this onto a name an existing Distance/Radius + /// constraint already references (unusual, but reachable — define + /// first, wire up the constraint's reference later) would otherwise + /// briefly collapse that geometry to a zero-length/zero-radius + /// degenerate state, which is a genuine numerical singularity for a + /// point-to-point distance constraint to grow back out of (no defined + /// direction to separate two exactly-coincident points from). `1` never + /// hits that. + pub(in crate::app) fn on_prop_param_add_new(&mut self) -> Task { + let i = self.active_tab; + let mut n = 1usize; + let name = loop { + let candidate = format!("param{n}"); + if !self.tabs[i].scene.named_parameters().contains(&candidate) { + break candidate; + } + n += 1; + }; + let pending = self.begin_undo(i, "Add named parameter", 0, true); + self.tabs[i].scene.record_undo_named_parameters_before(); + let _ = self.tabs[i].scene.named_parameters_mut().set(&name, "1"); + self.tabs[i].scene.sync_native_parametric_graph(); + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + self.refresh_properties(); + Task::none() + } + + pub(super) fn apply_continuous_constraints(&mut self, i: usize, new_handles: &[acadrust::Handle]) { + let supported_creation = self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| { + matches!( + command.name(), + "LINE" + | "PLINE" + | "POLYLINE" + | "RECTANG" + | "RECTANGLE" + | "POLYGON" + | "CIRCLE" + | "ARC" + ) + }); + if supported_creation { + self.apply_inferred_constraints(i, new_handles); + } + } + + pub(in crate::app) fn apply_inferred_constraints( + &mut self, + i: usize, + changed_handles: &[acadrust::Handle], + ) { + if !self.constraint_infer || changed_handles.is_empty() { + return; + } + let scope = self.tabs[i].current_parametric_scope(); + let owner = scope.owner_handle(&self.tabs[i].scene.document); + let candidates = self.tabs[i] + .scene + .document + .block_records + .iter() + .find(|record| record.handle == owner) + .map(|record| record.entity_handles.clone()) + .unwrap_or_default(); + let inferred = self.tabs[i].scene.inferred_parametric_constraints( + scope, + &candidates, + &self.auto_constrain_settings, + ); + let inferred: Vec<_> = inferred + .into_iter() + .filter(|(_, refs)| { + refs.iter() + .any(|reference| changed_handles.contains(&reference.entity)) + }) + .collect(); + if inferred.is_empty() { + return; + } + let before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, before); + let mut touched = Vec::new(); + for (kind, refs) in inferred { + touched.extend(refs.iter().map(|reference| reference.entity)); + let id = self.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .add(kind, refs, None); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + } + touched.sort_unstable_by_key(|handle| handle.value()); + touched.dedup(); + let changes: Vec<_> = touched + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &[], + self.constraint_solve_mode, + ); + } +} diff --git a/src/app/command_driver/property_match.rs b/src/app/command_driver/property_match.rs new file mode 100644 index 000000000..77c9e555f --- /dev/null +++ b/src/app/command_driver/property_match.rs @@ -0,0 +1,612 @@ +use super::*; + +impl OpenCADStudio { + pub(super) fn handle_match_entity_layer(&mut self, dest: Vec, src: Handle) { + let i = self.active_tab; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + let src_layer = self.tabs[i] + .scene + .document + .get_entity(src) + .map(|e| e.common().layer.clone()); + let dest: Vec<_> = dest + .into_iter() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); + if dest.is_empty() { + self.command_line + .push_info(crate::t!("No editable objects selected.").as_ref()); + } else if let Some(layer) = src_layer { + self.push_undo_snapshot(i, "LAYMATCH"); + for h in &dest { + if let Some(e) = self.tabs[i].scene.document.get_entity_mut(*h) { + e.as_entity_mut().set_layer(layer.clone()); + } + } + // New layer changes the baked by-layer colour/linetype/ + // lineweight — re-tessellate the moved entities so they + // repaint immediately (issue #231 class). + self.invalidate_property_targets(i, &dest); + self.tabs[i].dirty = true; + self.command_line + .push_info(crate::tf!("Layer matched to \"{layer}\".").as_ref()); + self.sync_ribbon_layers(); + } else { + self.command_line + .push_error(crate::t!("Source object not found.").as_ref()); + } + } + + + pub(super) fn handle_match_properties( + &mut self, + mut dest: Vec, + src: Handle, + ) -> Option> { + let i = self.active_tab; + dest.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + if dest.is_empty() { + return Some(Task::none()); + } + // The command stays active after each apply so more targets + // can keep being picked; Enter / Esc ends it (#362). + let src_clone = self.tabs[i].scene.document.get_entity(src).cloned(); + let src_common = src_clone.as_ref().map(|e| e.common().clone()); + let thickness = src_clone + .as_ref() + .and_then(crate::scene::view::dispatch::entity_thickness); + // This application record is the hatch background colour. + // Keep the outer Option to distinguish "source is not a + // hatch" from "source hatch has no background". + let hatch_background_xdata: Option>> = + match src_clone.as_ref() { + Some(acadrust::EntityType::Hatch(h)) => Some( + h.common + .extended_data + .get_record("HATCHBACKGROUNDCOLOR") + .map(|r| r.values.clone()), + ), + _ => None, + }; + // Dimension-style overrides ride the ACAD record, identified + // by a leading DSTYLE string. Matching replicates that payload + // (or clears the destination when the source has none). + let dstyle_xdata: Option> = src_clone + .as_ref() + .filter(|e| { + matches!( + e, + acadrust::EntityType::Dimension(_) | acadrust::EntityType::Leader(_) + ) + }) + .map(|e| crate::entities::dim_override::pairs(&e.common().extended_data)); + + if let Some(common) = src_common { + self.apply_property_op(i, "MATCHPROP", &dest, |app, handle| { + let mut is_dim = false; + let mut is_hatch = false; + if let Some(e) = app.tabs[i].scene.document.get_entity_mut(handle) { + e.as_entity_mut().set_layer(common.layer.clone()); + crate::scene::view::dispatch::apply_color(e, common.color); + crate::scene::view::dispatch::apply_line_weight(e, common.line_weight); + { + let dst_common = e.common_mut(); + dst_common.linetype = common.linetype.clone(); + dst_common.linetype_handle = common.linetype_handle; + dst_common.linetype_scale = common.linetype_scale; + dst_common.transparency = common.transparency.clone(); + dst_common.color_name = common.color_name.clone(); + dst_common.color_book_handle = common.color_book_handle; + dst_common.full_visual_style_handle = common.full_visual_style_handle; + dst_common.face_visual_style_handle = common.face_visual_style_handle; + dst_common.edge_visual_style_handle = common.edge_visual_style_handle; + dst_common.material_flags = common.material_flags; + dst_common.material_handle = common.material_handle; + dst_common.shadow_flags = common.shadow_flags; + dst_common.plotstyle_flags = common.plotstyle_flags; + dst_common.plotstyle_handle = common.plotstyle_handle; + } + if let Some(value) = thickness { + crate::scene::view::dispatch::set_entity_thickness(e, value); + } + if let Some(se) = &src_clone { + is_dim = matches!(e, acadrust::EntityType::Dimension(_)); + is_hatch = matches!(e, acadrust::EntityType::Hatch(_)); + match_special_props(se, e); + } + } + if is_hatch { + if let Some(values) = &hatch_background_xdata { + crate::scene::view::dispatch::set_entity_xdata( + &mut app.tabs[i].scene.document, + handle, + "HATCHBACKGROUNDCOLOR", + values.clone(), + ); + } + } + // Dim-style overrides follow the style for dimension / + // leader destinations — through set_entity_xdata so no + // stale raw record survives. + if dstyle_xdata.is_some() + || matches!( + app.tabs[i].scene.document.get_entity(handle), + Some(acadrust::EntityType::Dimension(_) | acadrust::EntityType::Leader(_)) + ) + { + if matches!( + app.tabs[i].scene.document.get_entity(handle), + Some(acadrust::EntityType::Dimension(_) | acadrust::EntityType::Leader(_)) + ) && matches!( + src_clone, + Some(acadrust::EntityType::Dimension(_) | acadrust::EntityType::Leader(_)) + ) { + crate::entities::dim_override::replace( + &mut app.tabs[i].scene.document, + handle, + dstyle_xdata.clone().unwrap_or_default(), + ); + } + } + // A restyled dimension renders from its baked *D block — + // drop the stale block so the new style shows (#398). + if is_dim { + app.tabs[i].scene.invalidate_dim_block_recorded(handle); + } + // Hatch fills render from a prebuilt model (#415). + app.tabs[i].scene.refresh_fill_model(handle); + }); + // Color / linetype / lineweight are baked into the cached + // wires at tessellation time; re-tessellate only the matched + // objects instead of rebuilding a large drawing. + let changes: Vec<_> = dest + .iter() + .copied() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); + self.command_line + .push_info(crate::tf!("Properties matched to {} object(s).", dest.len()).as_ref()); + // Clear the consumed target selection and keep prompting. + self.tabs[i].scene.deselect_all(); + if let Some(cmd) = &self.tabs[i].active_cmd { + self.command_line.push_info(&cmd.prompt()); + } + } else { + self.command_line + .push_error(crate::t!("Source object not found.").as_ref()); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + } + None + } + +} + +/// MATCHPROP special properties (#281): copy every STYLE-affecting field from +/// `src` to `dst` when the destination supports it — never content (text +/// strings, block names) or placement (positions, rotations of the object +/// itself). Text formatting crosses TEXT ↔ MTEXT; the dimension style crosses +/// Dimension / Leader / Tolerance. +fn match_special_props(src: &acadrust::EntityType, dst: &mut acadrust::EntityType) { + use acadrust::EntityType as E; + + // Text-ish source formatting. + let text_fmt = match src { + E::Text(t) => Some(( + t.style.clone(), + t.height, + Some(t.width_factor), + Some(t.oblique_angle), + )), + E::MText(m) => Some((m.style.clone(), m.height, None, None)), + E::AttributeDefinition(a) => Some(( + a.text_style.clone(), + a.height, + Some(a.width_factor), + Some(a.oblique_angle), + )), + E::AttributeEntity(a) => Some(( + a.text_style.clone(), + a.height, + Some(a.width_factor), + Some(a.oblique_angle), + )), + _ => None, + }; + if let Some((style, height, wf, ob)) = text_fmt { + match dst { + E::Text(t) => { + t.style = style.clone(); + t.height = height; + if let Some(value) = wf { + t.width_factor = value; + } + if let Some(value) = ob { + t.oblique_angle = value; + } + } + E::MText(m) => { + m.style = style.clone(); + m.height = height; + } + E::AttributeDefinition(a) => { + a.text_style = style.clone(); + a.height = height; + if let Some(value) = wf { + a.width_factor = value; + } + if let Some(value) = ob { + a.oblique_angle = value; + } + } + E::AttributeEntity(a) => { + a.text_style = style; + a.height = height; + if let Some(value) = wf { + a.width_factor = value; + } + if let Some(value) = ob { + a.oblique_angle = value; + } + } + _ => {} + } + } + // MText-only extras (line spacing, background fill). + if let (E::MText(sm), E::MText(dm)) = (src, dst as &mut E) { + dm.drawing_direction = sm.drawing_direction; + dm.line_spacing_factor = sm.line_spacing_factor; + dm.line_spacing_style = sm.line_spacing_style; + dm.background_fill_flags = sm.background_fill_flags; + dm.background_scale = sm.background_scale; + dm.background_color = sm.background_color; + dm.background_transparency = sm.background_transparency; + } + + // Dimension style name crosses the three dim-styled families. + let dim_style = match src { + E::Dimension(d) => Some(d.base().style_name.clone()), + E::Leader(l) => Some(l.dimension_style.clone()), + E::Tolerance(t) => Some(t.dimension_style_name.clone()), + _ => None, + }; + if let Some(ds) = dim_style { + match dst { + E::Dimension(d) => d.base_mut().style_name = ds, + E::Leader(l) => l.dimension_style = ds, + E::Tolerance(t) => t.dimension_style_name = ds, + _ => {} + } + } + + // Hatch pattern / gradient — everything but the boundary. + if let (E::Hatch(sh), E::Hatch(dh)) = (src, dst as &mut E) { + dh.pattern = sh.pattern.clone(); + dh.pattern_type = sh.pattern_type; + dh.pattern_angle = sh.pattern_angle; + dh.pattern_scale = sh.pattern_scale; + dh.is_solid = sh.is_solid; + dh.is_double = sh.is_double; + dh.style = sh.style; + dh.gradient_color = sh.gradient_color.clone(); + } + + // Polyline display style crosses lightweight and legacy 2D polylines. + // Per-vertex/tapered widths are resampled over the destination vertices; + // they must not be flattened into the source's constant-width field. + if let Some(style) = PolylineMatchStyle::from_entity(src) { + style.apply_to(dst); + } + + if let (E::Leader(sl), E::Leader(dl)) = (src, dst as &mut E) { + dl.arrow_enabled = sl.arrow_enabled; + dl.path_type = sl.path_type; + dl.hookline_direction = sl.hookline_direction; + dl.hookline_enabled = sl.hookline_enabled; + dl.override_color = sl.override_color; + dl.dimension_gap = sl.dimension_gap; + dl.arrowhead_type = sl.arrowhead_type; + dl.arrow_size = sl.arrow_size; + dl.byblock_color = sl.byblock_color; + } + if let (E::Tolerance(st), E::Tolerance(dt)) = (src, dst as &mut E) { + dt.dimension_style_handle = st.dimension_style_handle; + dt.text_height = st.text_height; + dt.dimension_gap = st.dimension_gap; + } + + // Paper-space geometry and view position stay; viewport display/plot + // styling and effective scale follow the source. + if let (E::Viewport(sv), E::Viewport(dv)) = (src, dst as &mut E) { + let scale = crate::scene::vp_effective_scale(sv.custom_scale, sv.view_height, sv.height); + dv.custom_scale = scale; + if scale.abs() > 1e-9 { + dv.view_height = dv.height / scale; + } + dv.status.locked = sv.status.locked; + dv.status.hide_plot = sv.status.hide_plot; + dv.render_mode = sv.render_mode; + dv.style_sheet = sv.style_sheet.clone(); + dv.shade_plot_mode = sv.shade_plot_mode; + dv.background_handle = sv.background_handle; + dv.shade_plot_handle = sv.shade_plot_handle; + dv.visual_style_handle = sv.visual_style_handle; + dv.default_lighting = sv.default_lighting; + dv.default_lighting_type = sv.default_lighting_type; + dv.brightness = sv.brightness; + dv.contrast = sv.contrast; + dv.ambient_color = sv.ambient_color; + } + + if let (E::Table(st), E::Table(dt)) = (src, dst as &mut E) { + dt.table_style_handle = st.table_style_handle; + dt.base_style = st.base_style.clone(); + dt.override_flag = st.override_flag; + dt.override_border_color = st.override_border_color; + dt.override_border_line_weight = st.override_border_line_weight; + dt.override_border_visibility = st.override_border_visibility; + dt.legacy_style_override = st.legacy_style_override.clone(); + dt.legacy_border_colors = st.legacy_border_colors.clone(); + dt.legacy_border_line_weights = st.legacy_border_line_weights.clone(); + dt.legacy_border_visibility = st.legacy_border_visibility.clone(); + } + + if let (E::MLine(sm), E::MLine(dm)) = (src, dst as &mut E) { + dm.style_handle = sm.style_handle; + dm.style_name = sm.style_name.clone(); + dm.style_element_count = sm.style_element_count; + dm.justification = sm.justification; + dm.scale_factor = sm.scale_factor; + // Stored segment offsets bake the old style into each vertex. Empty + // data deliberately selects the renderer's style-derived fallback. + for vertex in &mut dm.vertices { + vertex.segments.clear(); + } + } + + // MultiLeader style + every style-affecting override; content and + // geometry (leader points, text, block handle) stay. + if let (E::MultiLeader(sm), E::MultiLeader(dm)) = (src, dst as &mut E) { + dm.style_handle = sm.style_handle; + dm.path_type = sm.path_type; + dm.line_color = sm.line_color; + dm.line_type_handle = sm.line_type_handle; + dm.line_weight = sm.line_weight; + dm.enable_landing = sm.enable_landing; + dm.enable_dogleg = sm.enable_dogleg; + dm.dogleg_length = sm.dogleg_length; + dm.arrowhead_handle = sm.arrowhead_handle; + dm.arrowhead_size = sm.arrowhead_size; + dm.text_style_handle = sm.text_style_handle; + dm.text_color = sm.text_color; + dm.text_frame = sm.text_frame; + dm.text_height = sm.text_height; + dm.text_left_attachment = sm.text_left_attachment; + dm.text_right_attachment = sm.text_right_attachment; + dm.text_top_attachment = sm.text_top_attachment; + dm.text_bottom_attachment = sm.text_bottom_attachment; + dm.text_attachment_direction = sm.text_attachment_direction; + dm.text_attachment_point = sm.text_attachment_point; + dm.text_alignment = sm.text_alignment; + dm.text_angle_type = sm.text_angle_type; + dm.text_direction_negative = sm.text_direction_negative; + dm.text_align_in_ipe = sm.text_align_in_ipe; + dm.block_content_color = sm.block_content_color; + dm.block_connection_type = sm.block_connection_type; + dm.block_scale = sm.block_scale; + dm.scale_factor = sm.scale_factor; + dm.property_override_flags = sm.property_override_flags; + dm.enable_annotation_scale = sm.enable_annotation_scale; + dm.extend_leader_to_text = sm.extend_leader_to_text; + dm.arrowhead_overrides = sm.arrowhead_overrides.clone(); + + let sc = &sm.context; + let dc = &mut dm.context; + dc.scale_factor = sc.scale_factor; + dc.text_height = sc.text_height; + dc.text_width = sc.text_width; + dc.text_boundary_height = sc.text_boundary_height; + dc.line_spacing_factor = sc.line_spacing_factor; + dc.line_spacing_style = sc.line_spacing_style; + dc.text_color = sc.text_color; + dc.text_attachment_point = sc.text_attachment_point; + dc.text_flow_direction = sc.text_flow_direction; + dc.text_alignment = sc.text_alignment; + dc.text_left_attachment = sc.text_left_attachment; + dc.text_right_attachment = sc.text_right_attachment; + dc.text_top_attachment = sc.text_top_attachment; + dc.text_bottom_attachment = sc.text_bottom_attachment; + dc.text_height_automatic = sc.text_height_automatic; + dc.word_break = sc.word_break; + dc.text_style_handle = sc.text_style_handle; + dc.block_content_scale = sc.block_content_scale; + dc.block_content_color = sc.block_content_color; + dc.block_connection_type = sc.block_connection_type; + dc.column_type = sc.column_type; + dc.column_width = sc.column_width; + dc.column_gutter = sc.column_gutter; + dc.column_flow_reversed = sc.column_flow_reversed; + dc.column_sizes = sc.column_sizes.clone(); + dc.background_fill_enabled = sc.background_fill_enabled; + dc.background_mask_fill_on = sc.background_mask_fill_on; + dc.background_fill_color = sc.background_fill_color; + dc.background_scale_factor = sc.background_scale_factor; + dc.background_transparency = sc.background_transparency; + dc.arrowhead_size = sc.arrowhead_size; + dc.landing_gap = sc.landing_gap; + dc.scale_handle = sc.scale_handle; + } + + // External-reference identity, placement and clip geometry stay. Only + // display controls/appearance are matched. + if let (E::RasterImage(si), E::RasterImage(di)) = (src, dst as &mut E) { + di.flags = si.flags; + di.clipping_enabled = si.clipping_enabled; + di.brightness = si.brightness; + di.contrast = si.contrast; + di.fade = si.fade; + di.clip_boundary.clip_mode = si.clip_boundary.clip_mode; + } + if let (E::Wipeout(sw), E::Wipeout(dw)) = (src, dst as &mut E) { + dw.flags = sw.flags; + dw.clipping_enabled = sw.clipping_enabled; + dw.brightness = sw.brightness; + dw.contrast = sw.contrast; + dw.fade = sw.fade; + dw.clip_mode = sw.clip_mode; + } + if let (E::Underlay(su), E::Underlay(du)) = (src, dst as &mut E) { + du.flags = su.flags; + du.contrast = su.contrast; + du.fade = su.fade; + du.clip_inverted = su.clip_inverted; + } +} + +struct PolylineMatchStyle { + plinegen: bool, + widths: Vec<(f64, f64)>, +} + +impl PolylineMatchStyle { + fn from_entity(entity: &acadrust::EntityType) -> Option { + use acadrust::EntityType as E; + + match entity { + E::LwPolyline(poly) => { + let widths = if poly.vertices.is_empty() { + vec![(poly.constant_width, poly.constant_width)] + } else { + poly.vertices + .iter() + .map(|v| { + ( + width_or_default(v.start_width, poly.constant_width), + width_or_default(v.end_width, poly.constant_width), + ) + }) + .collect() + }; + Some(Self { + plinegen: poly.plinegen, + widths, + }) + } + E::Polyline2D(poly) => { + let widths = if poly.vertices.is_empty() { + vec![(poly.start_width, poly.end_width)] + } else { + poly.vertices + .iter() + .map(|v| { + ( + width_or_default(v.start_width, poly.start_width), + width_or_default(v.end_width, poly.end_width), + ) + }) + .collect() + }; + Some(Self { + plinegen: poly.flags.bits() + & acadrust::entities::PolylineFlags::LINETYPE_CONTINUOUS.bits() + != 0, + widths, + }) + } + _ => None, + } + } + + fn apply_to(&self, entity: &mut acadrust::EntityType) { + use acadrust::EntityType as E; + + match entity { + E::LwPolyline(poly) => { + poly.plinegen = self.plinegen; + let sampled = resample_widths(&self.widths, poly.vertices.len()); + let first = sampled.first().copied().unwrap_or((0.0, 0.0)); + let can_use_constant = widths_are_same(&sampled) && nearly_equal(first.0, first.1); + poly.constant_width = if can_use_constant { first.0 } else { 0.0 }; + for (vertex, (start, end)) in poly.vertices.iter_mut().zip(sampled) { + if can_use_constant { + vertex.start_width = 0.0; + vertex.end_width = 0.0; + } else { + vertex.start_width = start; + vertex.end_width = end; + } + } + } + E::Polyline2D(poly) => { + let mut bits = poly.flags.bits(); + let flag = acadrust::entities::PolylineFlags::LINETYPE_CONTINUOUS.bits(); + if self.plinegen { + bits |= flag; + } else { + bits &= !flag; + } + poly.flags = acadrust::entities::PolylineFlags::from_bits(bits); + let sampled = resample_widths(&self.widths, poly.vertices.len()); + let first = sampled.first().copied().unwrap_or((0.0, 0.0)); + let can_use_defaults = widths_are_same(&sampled); + if can_use_defaults { + poly.start_width = first.0; + poly.end_width = first.1; + } else { + poly.start_width = 0.0; + poly.end_width = 0.0; + } + for (vertex, (start, end)) in poly.vertices.iter_mut().zip(sampled) { + if can_use_defaults { + vertex.start_width = 0.0; + vertex.end_width = 0.0; + } else { + vertex.start_width = start; + vertex.end_width = end; + } + } + } + _ => {} + } + } +} + +fn width_or_default(value: f64, default: f64) -> f64 { + if value.abs() <= 1e-12 { + default + } else { + value + } +} + +fn nearly_equal(a: f64, b: f64) -> bool { + (a - b).abs() <= 1e-9 +} + +fn widths_are_same(widths: &[(f64, f64)]) -> bool { + let Some(first) = widths.first() else { + return true; + }; + widths + .iter() + .all(|value| nearly_equal(value.0, first.0) && nearly_equal(value.1, first.1)) +} + +fn resample_widths(source: &[(f64, f64)], count: usize) -> Vec<(f64, f64)> { + if count == 0 || source.is_empty() { + return Vec::new(); + } + if count == 1 || source.len() == 1 { + return vec![source[0]; count]; + } + (0..count) + .map(|index| { + let source_index = index.saturating_mul(source.len() - 1) / count.saturating_sub(1); + source[source_index] + }) + .collect() +} diff --git a/src/app/command_driver/solid3d.rs b/src/app/command_driver/solid3d.rs new file mode 100644 index 000000000..ba6f3fa60 --- /dev/null +++ b/src/app/command_driver/solid3d.rs @@ -0,0 +1,773 @@ +use super::*; + +impl OpenCADStudio { + pub(super) fn handle_extrude_entities(&mut self, result: CmdResult) -> Option> { + let i = self.active_tab; + let CmdResult::ExtrudeEntities { + handles, + extent, + mode, + taper_angle, + color: _, + } = result + else { + unreachable!("router only routes the matching variant"); + }; + let delete_sources = self.delete_objects != 0; + if handles.is_empty() + || handles + .iter() + .any(|handle| self.reject_locked_edit(i, *handle)) + { + self.tabs[i].active_cmd = None; + return Some(Task::none()); + } + use crate::command::{ExtrudeExtent, ExtrudeMode}; + use crate::modules::insert::solid3d_cmds::{empty_extruded_surface, empty_solid3d}; + use crate::scene::model::sweep_model; + let path = match extent { + ExtrudeExtent::Path(handle) => self.tabs[i] + .scene + .document + .get_entity(handle) + .cloned() + .map(|entity| (handle, entity)), + _ => None, + }; + if matches!(extent, ExtrudeExtent::Path(_)) && path.is_none() { + self.command_line + .push_error(crate::t!("EXTRUDE: path entity not found.").as_ref()); + self.tabs[i].active_cmd = None; + return Some(Task::none()); + } + let pending = self.begin_undo(i, "EXTRUDE", handles.len(), true); + let mut created_handles = Vec::new(); + let mut consumed = Vec::new(); + let mut failed = 0usize; + for handle in &handles { + let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() + else { + failed += 1; + continue; + }; + let Some((profile, closed)) = sweep_model::extrusion_profile_of(&entity) else { + failed += 1; + continue; + }; + let direction = match extent { + ExtrudeExtent::Height(height) => profile.plane.normal().map(|normal| { + glam::DVec3::new( + normal[0] * height, + normal[1] * height, + normal[2] * height, + ) + }), + ExtrudeExtent::Direction(direction) => Some(direction), + ExtrudeExtent::Path(_) => None, + }; + let path_direction = path + .as_ref() + .and_then(|(_, path)| sweep_model::straight_path_direction(path)) + .map(glam::DVec3::from_array); + // The creation mode controls closed profiles. Open curves + // always create sheet surfaces. + let creates_surface = matches!(mode, ExtrudeMode::Surface) || !closed; + let body = match (creates_surface, &path, direction) { + (false, Some((_, path)), _) => { + sweep_model::extruded_along_path(&entity, path, taper_angle) + } + (false, None, Some(direction)) => sweep_model::extruded_direction( + &entity, + direction.to_array(), + taper_angle, + ), + (true, None, Some(direction)) => sweep_model::extruded_surface( + &entity, + direction.to_array(), + taper_angle, + ), + (true, Some(_), _) => path_direction.and_then(|direction| { + sweep_model::extruded_surface( + &entity, + direction.to_array(), + taper_angle, + ) + }), + _ => None, + }; + let Some(body) = body else { + failed += 1; + continue; + }; + let created = if creates_surface { + let direction = direction.or(path_direction).unwrap_or(glam::DVec3::ZERO); + let history = path + .is_none() + .then(|| { + sweep_model::extrusion_history( + &entity, + None, + direction.to_array(), + taper_angle, + profile.plane.origin, + ) + }) + .flatten(); + let mut surface = empty_extruded_surface(direction, taper_angle); + if let ( + Some(acadrust::objects::SolidHistoryOperation::Extrusion(value)), + acadrust::EntityType::Surface(entity), + ) = (&history, &mut surface) + { + if let Some(data) = + crate::scene::model::solid_history::extrusion_surface_data(value) + { + entity.surface_data = data; + } + } + match history { + Some(history) => { + self.add_surface_model_with_history(surface, body, history) + } + None => self.add_surface_model(surface, body), + } + } else { + let direction = direction.unwrap_or(glam::DVec3::ZERO); + let history = path + .as_ref() + .and_then(|(_, path)| { + sweep_model::sweep_history( + &entity, + path, + taper_angle, + profile.plane.origin, + ) + }) + .or_else(|| { + sweep_model::extrusion_history( + &entity, + None, + direction.to_array(), + taper_angle, + profile.plane.origin, + ) + }) + .unwrap_or_else(|| crate::scene::model::solid_history::brep_op(&body)); + self.add_solid_model(empty_solid3d(), body, history) + }; + if created.is_null() { + failed += 1; + } else { + created_handles.push(created); + if delete_sources { + consumed.push(*handle); + } + } + } + if delete_sources && !created_handles.is_empty() { + consumed.sort_unstable_by_key(|handle| handle.value()); + consumed.dedup(); + self.tabs[i].scene.erase_entities(&consumed); + } + if !created_handles.is_empty() { + self.tabs[i].scene.deselect_all(); + for handle in &created_handles { + self.tabs[i].scene.select_entity(*handle, true); + } + self.tabs[i].dirty = true; + self.command_line.push_output( + crate::tf!( + "EXTRUDE: created %{created} object(s); %{failed} source(s) could not be extruded.", + created = created_handles.len(), + failed = failed + ) + .as_ref(), + ); + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + } else { + self.command_line.push_error(crate::t!("EXTRUDE: no selected object could be extruded with the requested options.").as_ref()); + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + self.refresh_properties(); + None + } + + pub(super) fn handle_thicken_entities(&mut self, handles: Vec, distance: f64) -> Option> { + let i = self.active_tab; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + + if handles.is_empty() { + self.command_line + .push_output(crate::t!("No surfaces selected.").as_ref()); + return Some(Task::none()); + } + if !distance.is_finite() { + self.command_line + .push_error(crate::t!("Requires numeric distance or two points.").as_ref()); + return Some(Task::none()); + } + if distance.abs() <= f64::EPSILON { + return Some(Task::none()); + } + + use crate::modules::insert::solid3d_cmds::empty_solid3d; + let pending = self.begin_undo(i, "THICKEN", handles.len(), true); + let mut created = 0usize; + let mut failed = 0usize; + let mut self_intersections = 0usize; + for handle in handles { + let Some(acadrust::EntityType::Surface(surface)) = + self.tabs[i].scene.document.get_entity(handle) + else { + failed += 1; + continue; + }; + let kernel_distance = + if surface.kind == acadrust::entities::SurfaceKind::Revolved { + -distance + } else { + distance + }; + let body = self.tabs[i] + .scene + .solid_models + .get(&handle) + .cloned() + .or_else(|| { + crate::scene::convert::solid3d_tess::kernel_surface_body(surface) + }); + let Some(body) = body else { + failed += 1; + continue; + }; + let solid = match cadkernel::brep::thicken(&body, kernel_distance) { + Ok(solid) => solid, + Err(cadkernel::brep::ThickenError::SelfIntersection) => { + failed += 1; + self_intersections += 1; + continue; + } + Err(_) => { + failed += 1; + continue; + } + }; + let history = crate::scene::model::solid_history::brep_op(&solid); + if self + .add_solid_model(empty_solid3d(), solid, history) + .is_null() + { + failed += 1; + } else { + created += 1; + } + } + + if created > 0 { + self.tabs[i].dirty = true; + } + if self_intersections > 0 { + self.command_line.push_error(&crate::tf!( + "{} surface(s) cannot be thickened because the offset intersects itself.", + self_intersections + )); + } + if failed > 0 { + self.command_line.push_output(&crate::tf!( + "THICKEN: created {} solid(s); {} surface(s) could not be thickened exactly.", + created, failed + )); + } + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + self.refresh_properties(); + None + } + + pub(super) fn handle_presspull_pick(&mut self, result: CmdResult) { + let CmdResult::PresspullPick { + handle, + point, + offset, + multiple: _, + } = result + else { + unreachable!("router only routes the matching variant"); + }; + self.presspull_pick(handle, point, offset); + } + + pub(super) fn handle_presspull_apply(&mut self, targets: Vec, distance: f64) { + self.presspull_apply(targets, distance); + } + + pub(super) fn handle_revolve_entities(&mut self, result: CmdResult) -> Option> { + let i = self.active_tab; + let CmdResult::RevolveEntities { + handles, + axis_start, + axis_end, + angle, + start_angle, + mode, + color: _, + } = result + else { + unreachable!("router only routes the matching variant"); + }; + if handles.is_empty() { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + return Some(Task::none()); + } + let delete_sources = self.delete_objects != 0; + use crate::command::ExtrudeMode; + use crate::modules::insert::solid3d_cmds::{empty_revolved_surface, empty_solid3d}; + use crate::scene::model::sweep_model; + let from = axis_start.to_array(); + let to = axis_end.to_array(); + let mut editable_handles = Vec::with_capacity(handles.len()); + let mut failed = 0usize; + for handle in handles { + if self.reject_locked_edit(i, handle) { + failed += 1; + } else { + editable_handles.push(handle); + } + } + let pending = if editable_handles.is_empty() { + None + } else { + self.begin_undo(i, "REVOLVE", editable_handles.len(), true) + }; + let mut created_handles = Vec::new(); + let mut consumed = Vec::new(); + for handle in &editable_handles { + let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() + else { + failed += 1; + continue; + }; + let Some((_, closed)) = sweep_model::extrusion_profile_of(&entity) else { + failed += 1; + continue; + }; + let creates_surface = mode == ExtrudeMode::Surface || !closed; + let created = if creates_surface { + let Some(body) = + sweep_model::revolved_surface(&entity, from, to, angle, start_angle) + else { + failed += 1; + continue; + }; + self.add_surface_model( + empty_revolved_surface( + &entity, + axis_start, + axis_end, + angle, + start_angle, + ), + body, + ) + } else { + let result = + sweep_model::revolve_history(&entity, from, to, angle, start_angle) + .and_then(|history| { + cadkernel::acis::rebuild_body(&history) + .ok() + .map(|solid| (solid, history)) + }) + .or_else(|| { + sweep_model::revolved(&entity, from, to, angle, start_angle) + .map(|solid| { + let history = + crate::scene::model::solid_history::brep_op(&solid); + (solid, history) + }) + }); + let Some((solid, history)) = result else { + failed += 1; + continue; + }; + self.add_solid_model(empty_solid3d(), solid, history) + }; + if created.is_null() { + failed += 1; + } else { + created_handles.push(created); + if delete_sources { + consumed.push(*handle); + } + } + } + if delete_sources && !created_handles.is_empty() { + consumed.sort_unstable_by_key(|handle| handle.value()); + consumed.dedup(); + self.tabs[i].scene.erase_entities(&consumed); + } + if !created_handles.is_empty() { + self.tabs[i].scene.deselect_all(); + for handle in &created_handles { + self.tabs[i].scene.select_entity(*handle, true); + } + self.tabs[i].dirty = true; + self.command_line.push_output( + crate::tf!( + "REVOLVE: created %{created} object(s); %{failed} source(s) could not be revolved.", + created = created_handles.len(), + failed = failed + ) + .as_ref(), + ); + } else { + self.command_line.push_error( + crate::t!("REVOLVE: no selected object could be revolved with the requested options.") + .as_ref(), + ); + } + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + self.refresh_properties(); + None + } + + pub(super) fn handle_sweep_entities(&mut self, result: CmdResult) { + let i = self.active_tab; + let CmdResult::SweepEntities { + handles, + path_handle, + mode, + options, + color: _, + } = result + else { + unreachable!("router only routes the matching variant"); + }; + use crate::command::ExtrudeMode; + use crate::modules::insert::solid3d_cmds::empty_solid3d; + use crate::scene::model::sweep_model; + let delete_objects = self.delete_objects; + let delete_profiles = delete_objects != 0; + let path = self.tabs[i].scene.document.get_entity(path_handle).cloned(); + let mut profiles = Vec::new(); + let mut failed = 0usize; + for handle in handles { + if handle == path_handle || self.reject_locked_edit(i, handle) { + failed += 1; + continue; + } + if let Some(entity) = self.tabs[i].scene.document.get_entity(handle).cloned() { + profiles.push((handle, entity)); + } else { + failed += 1; + } + } + let selection = profiles + .iter() + .map(|(_, entity)| entity.clone()) + .collect::>(); + let options = sweep_model::sweep_selection_options(&selection, options); + let pending = if profiles.is_empty() { + None + } else { + self.begin_undo(i, "SWEEP", profiles.len(), true) + }; + let mut created_handles = Vec::new(); + let mut consumed = Vec::new(); + for (handle, profile) in profiles { + let result = path.as_ref().zip(options).and_then(|(path, options)| { + let record = sweep_model::sweep_record(&profile, path, options)?; + let (_, _, closed) = cadkernel::acis::sweep_profile_geometry( + record.sweep_entity.as_ref()?, + record.sweep_entity_transform, + ) + .ok()?; + let surface = mode == ExtrudeMode::Surface || !closed; + let body = + cadkernel::acis::rebuild_sweep_with_mode(&record, surface).ok()?; + Some((body, record, surface)) + }); + let Some((body, record, surface)) = result else { + failed += 1; + continue; + }; + let deletes_path = + crate::app::delobj_deletes_auxiliary(delete_objects, surface); + if deletes_path && self.tabs[i].scene.is_layer_locked(path_handle) { + failed += 1; + continue; + } + let created = if surface { + self.add_surface_model(sweep_model::swept_surface_entity(&record), body) + } else { + self.add_solid_model( + empty_solid3d(), + body, + acadrust::objects::SolidHistoryOperation::Sweep(record), + ) + }; + if created.is_null() { + failed += 1; + } else { + created_handles.push(created); + if delete_profiles { + consumed.push(handle); + } + if deletes_path { + consumed.push(path_handle); + } + } + } + if !created_handles.is_empty() { + consumed.sort_unstable_by_key(|handle| handle.value()); + consumed.dedup(); + self.tabs[i].scene.erase_entities(&consumed); + self.tabs[i].scene.deselect_all(); + for handle in &created_handles { + self.tabs[i].scene.select_entity(*handle, true); + } + self.tabs[i].dirty = true; + self.command_line.push_output(crate::tf!( + "SWEEP: created {created} object(s); {failed} source(s) could not be swept.", + created = created_handles.len(), failed = failed + ).as_ref()); + } else { + self.command_line.push_error( + crate::t!("SWEEP: could not sweep the profile along the path.").as_ref(), + ); + } + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + self.refresh_properties(); + } + + pub(super) fn handle_loft_entities(&mut self, result: CmdResult) -> Option> { + let i = self.active_tab; + let CmdResult::LoftEntities { + sections, + guides, + path, + mode, + options, + color: _, + } = result + else { + unreachable!("router only routes the matching variant"); + }; + use crate::command::LoftSectionSelection; + use crate::modules::insert::solid3d_cmds::empty_solid3d; + use crate::scene::model::loft_command_model; + let mut sources = sections + .iter() + .flat_map(|section| match section { + LoftSectionSelection::Entity(handle) => vec![*handle], + LoftSectionSelection::Join(handles) => handles.clone(), + LoftSectionSelection::Point(_) => Vec::new(), + }) + .collect::>(); + sources.sort_unstable_by_key(|handle| handle.value()); + sources.dedup(); + let delete_objects = self.delete_objects; + let delete_sections = delete_objects != 0; + let section_locked = delete_sections + && sources + .iter() + .any(|handle| self.tabs[i].scene.is_layer_locked(*handle)); + let available = sources + .iter() + .chain(guides.iter()) + .copied() + .chain(path) + .filter_map(|handle| { + self.tabs[i] + .scene + .document + .get_entity(handle) + .cloned() + .map(|entity| (handle, entity)) + }) + .collect::>(); + let result = if section_locked { + Err("LOFT: a source is on a locked layer; disable source deletion or unlock it.".to_string()) + } else { + loft_command_model::record(§ions, &guides, path, &available, mode, options) + .and_then(|record| { + cadkernel::acis::rebuild_loft_with_options(&record) + .map(|body| (body, record)) + }) + }; + match result { + Ok((body, record)) => { + let surface = record + .parameters + .as_ref() + .is_some_and(|settings| settings.surface); + let delete_auxiliary = + crate::app::delobj_deletes_auxiliary(delete_objects, surface); + if delete_auxiliary + && guides + .iter() + .copied() + .chain(path) + .any(|handle| self.tabs[i].scene.is_layer_locked(handle)) + { + self.command_line.push_error( + "LOFT: a source is on a locked layer; disable source deletion or unlock it.", + ); + return Some(Task::none()); + } + let dirty_before = self.tabs[i].dirty; + let pending = self.begin_undo(i, "LOFT", 1, true); + let created = if surface { + let handle = self.add_surface_model( + loft_command_model::surface_entity(&record), + body, + ); + if !handle.is_null() { + self.tabs[i].scene.create_solid_history( + handle, + acadrust::objects::SolidHistoryOperation::Loft(record), + ); + } + handle + } else { + self.add_solid_model( + empty_solid3d(), + body, + acadrust::objects::SolidHistoryOperation::Loft(record), + ) + }; + if created.is_null() { + // The creation helper already removed its provisional + // result. Discard its empty undo recording as well. + self.tabs[i].scene.take_undo_recording(); + self.tabs[i].dirty = dirty_before; + self.command_line.push_error(crate::t!("LOFT could not create a complete display. The source sections were preserved.").as_ref()); + return Some(Task::none()); + } else { + let mut consumed = if delete_sections { + sources.clone() + } else { + Vec::new() + }; + if delete_auxiliary { + consumed.extend(guides.iter().copied()); + consumed.extend(path); + } + consumed.sort_unstable_by_key(|handle| handle.value()); + consumed.dedup(); + self.tabs[i].scene.erase_entities(&consumed); + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.select_entity(created, true); + self.tabs[i].dirty = true; + let message = if surface { + crate::t!("LOFT: surface created.") + } else { + crate::t!("LOFT: solid created.") + }; + self.command_line.push_output(message.as_ref()); + } + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + } + Err(error) => { + self.command_line.push_error(&error); + return Some(Task::none()); + } + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + self.refresh_properties(); + None + } + + pub(super) fn handle_solid_edge_blend(&mut self, result: CmdResult) -> Task { + let i = self.active_tab; + let CmdResult::SolidEdgeBlend { + handle, + edges, + base_face, + value, + other_value, + fillet, + } = result + else { + unreachable!("router only routes the matching variant"); + }; + let task = + self.solid_edge_blend(handle, &edges, base_face, value, other_value, fillet); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + task + } + + pub(super) fn handle_solid_shell(&mut self, handle: Handle, actions: Vec, distance: f64) -> Task { + let i = self.active_tab; + let task = self.solid_shell(handle, &actions, distance); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + task + } + + pub(super) fn handle_solid_subtract(&mut self, bases: Vec, cutters: Vec, convert_meshes: bool) -> Task { + let i = self.active_tab; + let task = self.solid_subtract(&bases, &cutters, convert_meshes); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + task + } + + pub(super) fn handle_slice_entities(&mut self, targets: Vec, plane: cadkernel::space::Plane, keep_point: Option) -> Task { + let i = self.active_tab; + let task = self.solid_slice(&targets, plane, keep_point); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + task + } + + pub(super) fn handle_slice_surface_entities(&mut self, targets: Vec, cutter: Box, keep_point: Option) -> Task { + let i = self.active_tab; + let task = self.solid_slice_surface(&targets, *cutter, keep_point); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + task + } +} diff --git a/src/app/command_driver/tests.rs b/src/app/command_driver/tests.rs new file mode 100644 index 000000000..b0e71a714 --- /dev/null +++ b/src/app/command_driver/tests.rs @@ -0,0 +1,1131 @@ +#[cfg(test)] +mod command_replacement_tests { + use super::super::*; + use acadrust::entities::{Circle, Line}; + use acadrust::types::Vector3; + + #[test] + fn one_to_one_edits_keep_identity_but_type_changes_do_not() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let tab = app.active_tab; + let handle = app.tabs[tab] + .scene + .add_entity(acadrust::EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(1.0, 0.0, 0.0), + ))); + let owner = app.tabs[tab] + .scene + .document + .get_entity(handle) + .unwrap() + .common() + .owner_handle; + + let kept = app.replace_command_entity( + tab, + handle, + vec![acadrust::EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(3.0, 0.0, 0.0), + ))], + ); + assert_eq!(kept, vec![handle]); + let edited = app.tabs[tab].scene.document.get_entity(handle).unwrap(); + assert_eq!(edited.common().owner_handle, owner); + assert!(matches!(edited, acadrust::EntityType::Line(line) if line.end.x == 3.0)); + + let allocated = app.replace_command_entity( + tab, + handle, + vec![acadrust::EntityType::Circle(Circle::from_coords( + 0.0, 0.0, 0.0, 2.0, + ))], + ); + assert_eq!(allocated.len(), 1); + assert_ne!(allocated[0], handle); + assert!(app.tabs[tab].scene.document.get_entity(handle).is_none()); + } + + #[test] + fn live_geometry_updates_keep_document_identity_and_display_properties() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let tab = app.active_tab; + let handle = app.tabs[tab] + .scene + .add_entity(acadrust::EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(1.0, 0.0, 0.0), + ))); + let expected = { + let entity = app.tabs[tab].scene.document.get_entity_mut(handle).unwrap(); + entity.common_mut().layer = "LIVE".to_string(); + entity.common_mut().invisible = true; + entity.common_mut().linetype_scale = 2.5; + entity.common().clone() + }; + + let _ = app.apply_cmd_result(CmdResult::UpdateLiveEntity { + handle, + entity: acadrust::EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(4.0, 0.0, 0.0), + )), + finish: false, + }); + + let updated = app.tabs[tab].scene.document.get_entity(handle).unwrap(); + assert_eq!(updated.common().handle, expected.handle); + assert_eq!(updated.common().owner_handle, expected.owner_handle); + assert_eq!(updated.common().layer, expected.layer); + assert_eq!(updated.common().invisible, expected.invisible); + assert_eq!(updated.common().linetype_scale, expected.linetype_scale); + assert!(matches!(updated, acadrust::EntityType::Line(line) if line.end.x == 4.0)); + } +} + +#[cfg(test)] +mod parametric_constraint_undo_tests { + use super::super::*; + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef, ParametricScope}; + + fn add_line(app: &mut OpenCADStudio, x1: f64, y1: f64, x2: f64, y2: f64) -> Handle { + app.tabs[app.active_tab] + .scene + .add_entity(acadrust::EntityType::Line( + acadrust::entities::Line::from_points( + acadrust::types::Vector3::new(x1, y1, 0.0), + acadrust::types::Vector3::new(x2, y2, 0.0), + ), + )) + } + + fn line_angle_deg(app: &OpenCADStudio, handle: Handle) -> f64 { + match app.tabs[app.active_tab].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Line(l)) => (l.end.y - l.start.y) + .atan2(l.end.x - l.start.x) + .to_degrees(), + other => panic!("expected a Line, got {other:?}"), + } + } + + /// Constraint state and solved geometry share one undo/redo transaction. + #[test] + fn adding_a_constraint_is_undoable_and_redoable_atomically() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let line = add_line(&mut app, 0.0, 0.0, 10.0, 3.0); + let original_angle = line_angle_deg(&app, line); + assert!((original_angle - 16.699244).abs() < 1e-3); + + let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { + kind: ConstraintKind::Horizontal, + refs: vec![ParametricRef::whole(line)], + driving_param: None, + label: "Horizontal constraint", + }); + assert!( + line_angle_deg(&app, line).abs() < 1e-6, + "constraint should have leveled the line" + ); + assert_eq!( + app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap() + .constraints + .len(), + 1 + ); + + app.undo_steps(1); + assert_eq!( + app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .map(|s| s.constraints.len()) + .unwrap_or(0), + 0, + "undo should remove the constraint record" + ); + assert!( + (line_angle_deg(&app, line) - original_angle).abs() < 1e-3, + "undo should restore the pre-constraint angle" + ); + + app.redo_steps(1); + assert!( + line_angle_deg(&app, line).abs() < 1e-6, + "redo should re-level the line" + ); + assert_eq!( + app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap() + .constraints + .len(), + 1, + "redo should restore the constraint record" + ); + } + + #[test] + fn perpendicular_initial_solve_rotates_parallel_second_line_in_kernel() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let first = add_line(&mut app, 0.0, 0.0, 10.0, 0.0); + let second = add_line(&mut app, 20.0, 0.0, 30.0, 0.0); + + let _ = app.apply_cmd_result(CmdResult::AddPerpendicularConstraint { + first: ParametricRef::whole(first), + second: ParametricRef::whole(second), + first_fixed: ParametricRef::whole(first), + second_start: ParametricRef::point(second, 0), + label: "Perpendicular constraint", + }); + + let line = |handle| match app.tabs[app.active_tab].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Line(line)) => line.clone(), + other => panic!("expected a Line, got {other:?}"), + }; + let fixed = line(first); + let moving = line(second); + assert_eq!(fixed.start, acadrust::types::Vector3::new(0.0, 0.0, 0.0)); + assert_eq!(fixed.end, acadrust::types::Vector3::new(10.0, 0.0, 0.0)); + assert!((moving.start - acadrust::types::Vector3::new(20.0, 0.0, 0.0)).length() < 1.0e-9); + assert!((moving.length() - 10.0).abs() < 1.0e-7); + assert!( + (fixed.end - fixed.start) + .dot(&(moving.end - moving.start)) + .abs() + < 1.0e-7, + "fixed={fixed:?}, moving={moving:?}" + ); + } + + #[test] + fn horizontal_initial_solve_uses_the_captured_axis_in_kernel() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let handle = add_line(&mut app, 0.0, 0.0, 5.0, 2.0); + let (original_start, original_end, original_length) = match app.tabs[app.active_tab] + .scene + .document + .get_entity(handle) + { + Some(acadrust::EntityType::Line(line)) => (line.start, line.end, line.length()), + other => panic!("expected a Line, got {other:?}"), + }; + let direction = acadrust::types::Vector3::new(3.0, 4.0, 0.0).normalize(); + + let _ = app.apply_cmd_result(CmdResult::AddHorizontalConstraint { + kind: crate::scene::parametric_constraints::ConstraintKind::Horizontal, + selection: crate::command::HorizontalConstraintSelection::Reference( + ParametricRef::whole(handle), + ), + direction, + label: "Horizontal constraint", + }); + + let line = match app.tabs[app.active_tab].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Line(line)) => line, + other => panic!("expected a Line, got {other:?}"), + }; + let solved = (line.end - line.start).normalize(); + assert!(solved.cross(&direction).length() < 1.0e-7); + assert!((line.length() - original_length).abs() < 1.0e-7); + let constraint = &app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap() + .constraints[0]; + assert_eq!(constraint.axis_direction, Some(direction)); + + app.undo_steps(1); + let line = match app.tabs[app.active_tab].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Line(line)) => line, + other => panic!("expected a Line after undo, got {other:?}"), + }; + assert_eq!((line.start, line.end), (original_start, original_end)); + assert_eq!( + app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .map(|set| set.constraints.len()) + .unwrap_or(0), + 0 + ); + + app.redo_steps(1); + let line = match app.tabs[app.active_tab].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Line(line)) => line, + other => panic!("expected a Line after redo, got {other:?}"), + }; + assert!((line.end - line.start).normalize().cross(&direction).length() < 1.0e-7); + } + + #[test] + fn horizontal_two_point_solve_keeps_the_first_point_fixed() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let first = add_line(&mut app, 0.0, 0.0, 0.0, 5.0); + let second = add_line(&mut app, 8.0, 3.0, 8.0, 7.0); + let pick = |handle, point| crate::command::CoincidentPick { + handle: Some(handle), + point, + whole_curve: false, + }; + + let _ = app.apply_cmd_result(CmdResult::AddHorizontalConstraint { + kind: crate::scene::parametric_constraints::ConstraintKind::Horizontal, + selection: crate::command::HorizontalConstraintSelection::Points( + pick(first, glam::DVec3::ZERO), + pick(second, glam::DVec3::new(8.0, 3.0, 0.0)), + ), + direction: acadrust::types::Vector3::UNIT_X, + label: "Horizontal constraint", + }); + + let line = |handle| match app.tabs[app.active_tab].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Line(line)) => line, + other => panic!("expected a Line, got {other:?}"), + }; + assert_eq!(line(first).start, acadrust::types::Vector3::ZERO); + assert!(line(second).start.y.abs() < 1.0e-8); + } + + #[test] + fn horizontal_minor_axis_rotates_an_ellipse_around_its_center() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let mut ellipse = acadrust::entities::Ellipse::default(); + ellipse.center = acadrust::types::Vector3::new(3.0, 7.0, 0.0); + ellipse.major_axis = acadrust::types::Vector3::new(3.0, 4.0, 0.0); + ellipse.minor_axis_ratio = 0.4; + let handle = app.tabs[app.active_tab] + .scene + .add_entity(acadrust::EntityType::Ellipse(ellipse)); + + let _ = app.apply_cmd_result(CmdResult::AddHorizontalConstraint { + kind: crate::scene::parametric_constraints::ConstraintKind::Horizontal, + selection: crate::command::HorizontalConstraintSelection::Reference( + ParametricRef::ellipse_minor_axis(handle), + ), + direction: acadrust::types::Vector3::UNIT_X, + label: "Horizontal constraint", + }); + + let ellipse = match app.tabs[app.active_tab].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Ellipse(ellipse)) => ellipse, + other => panic!("expected an Ellipse, got {other:?}"), + }; + assert!((ellipse.center - acadrust::types::Vector3::new(3.0, 7.0, 0.0)).length() < 1.0e-9); + assert!(ellipse.major_axis.x.abs() < 1.0e-7); + assert!((ellipse.major_axis.length() - 5.0).abs() < 1.0e-7); + assert!((ellipse.minor_axis_ratio - 0.4).abs() < 1.0e-9); + } + + /// Drawing commands must not create parametric constraints implicitly. + #[test] + fn drawing_a_line_onto_an_existing_endpoint_does_not_add_a_constraint() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let _existing = add_line(&mut app, 0.0, 0.0, 5.0, 0.0); + + let new_line = acadrust::EntityType::Line(acadrust::entities::Line::from_points( + acadrust::types::Vector3::new(5.0, 0.0, 0.0), // exactly on `existing`'s end point + acadrust::types::Vector3::new(10.0, 0.0, 0.0), + )); + app.tabs[app.active_tab].active_cmd = Some(Box::new( + crate::modules::draw::draw::line::LineCommand::new(), + )); + let _ = app.apply_cmd_result(CmdResult::CommitEntity(new_line)); + + assert_eq!( + app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .map(|s| s.constraints.len()) + .unwrap_or(0), + 0, + "a drawing command must not add an implicit constraint" + ); + } + + /// Conflict resolution removes one flagged constraint and is undoable. + #[test] + fn resolve_one_parametric_conflict_removes_a_flagged_constraint_and_is_undoable() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let line = add_line(&mut app, 0.0, 0.0, 10.0, 3.0); + + let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { + kind: ConstraintKind::Horizontal, + refs: vec![ParametricRef::whole(line)], + driving_param: None, + label: "Horizontal constraint", + }); + let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { + kind: ConstraintKind::Horizontal, + refs: vec![ParametricRef::whole(line)], + driving_param: None, + label: "Horizontal constraint", + }); // exact duplicate — flagged redundant once bumped + app.tabs[app.active_tab] + .scene + .bump_entities(&[(line, crate::scene::ChangeKind::Modified)]); + + let set = app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap(); + assert_eq!(set.constraints.len(), 2); + assert_eq!( + set.conflicts.len(), + 1, + "the duplicate should have been flagged" + ); + + app.resolve_one_parametric_conflict(); + let set = app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap(); + assert_eq!( + set.constraints.len(), + 1, + "one of the two duplicates should have been removed" + ); + + app.undo_steps(1); + let set = app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap(); + assert_eq!( + set.constraints.len(), + 2, + "undo should restore the removed constraint" + ); + } + + #[test] + fn deleting_one_parametric_constraint_is_undoable() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let line = add_line(&mut app, 0.0, 0.0, 10.0, 3.0); + let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { + kind: ConstraintKind::Horizontal, + refs: vec![ParametricRef::whole(line)], + driving_param: None, + label: "Horizontal constraint", + }); + let id = app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap() + .constraints[0] + .id; + app.tabs[app.active_tab].scene.selected_constraint = Some(id); + + app.delete_parametric_constraint(id); + assert!(app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap() + .constraints + .is_empty()); + assert_eq!(app.tabs[app.active_tab].scene.selected_constraint, None); + + app.undo_steps(1); + assert_eq!( + app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap() + .constraints + .len(), + 1 + ); + } + + /// `named_parameters_design.md` stage 4: applying the PARAMETERS editor's + /// working buffer both defines the parameter and re-solves whatever + /// constraint already references it by name — the entire point of a + /// *named* parameter, not just data plumbing. + #[test] + fn applying_named_parameter_rows_defines_and_resolves_referencing_constraints() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let line = add_line(&mut app, 0.0, 0.0, 10.0, 0.0); + + // The constraint is added *before* the parameter exists -- allowed, + // same as `ParameterTable::set`'s own forward-reference tolerance; + // it just doesn't resolve to anything until the parameter is defined. + let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { + kind: ConstraintKind::Distance, + refs: vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], + driving_param: Some(crate::scene::named_parameters::DrivingValue::Named( + "target_len".to_string(), + )), + label: "Distance constraint", + }); + + app.named_parameter_editor_rows = + vec![crate::ui::window::named_parameters::ParamEditorRow { + name: "target_len".to_string(), + formula: "8".to_string(), + }]; + app.apply_named_parameter_editor_rows(); + + assert_eq!( + app.tabs[app.active_tab] + .scene + .named_parameters() + .resolve("target_len"), + Ok(8.0) + ); + let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) { + Some(acadrust::EntityType::Line(l)) => (l.start, l.end), + other => panic!("expected a Line, got {other:?}"), + }; + let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); + assert!( + (len - 8.0).abs() < 1e-6, + "length should track the applied parameter, got {len}" + ); + + // Redefining the parameter and re-applying should ripple again. + app.named_parameter_editor_rows[0].formula = "3".to_string(); + app.apply_named_parameter_editor_rows(); + let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) { + Some(acadrust::EntityType::Line(l)) => (l.start, l.end), + other => panic!("expected a Line, got {other:?}"), + }; + let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); + assert!( + (len - 3.0).abs() < 1e-6, + "length should track the redefined parameter, got {len}" + ); + + app.undo_steps(1); + assert_eq!( + app.tabs[app.active_tab] + .scene + .named_parameters() + .resolve("target_len"), + Ok(8.0) + ); + let length_after_undo = match app.tabs[app.active_tab].scene.document.get_entity(line) { + Some(acadrust::EntityType::Line(line)) => line.length(), + other => panic!("expected a Line, got {other:?}"), + }; + assert!((length_after_undo - 8.0).abs() < 1e-6); + + app.redo_steps(1); + assert_eq!( + app.tabs[app.active_tab] + .scene + .named_parameters() + .resolve("target_len"), + Ok(3.0) + ); + } + + /// The no-selection page must expose the active drawing's named parameters. + #[test] + fn no_selection_properties_exposes_named_parameters() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + app.tabs[app.active_tab] + .scene + .named_parameters_mut() + .set("width", "12") + .unwrap(); + + app.refresh_properties(); + + assert!(app.tabs[app.active_tab] + .properties + .sections + .iter() + .flat_map(|section| §ion.props) + .any(|property| matches!( + &property.value, + crate::scene::model::object::PropValue::ParamRow { name, .. } + if name == "width" + ))); + assert!(app.tabs[app.active_tab] + .properties + .sections + .iter() + .flat_map(|section| §ion.props) + .any(|property| matches!( + property.value, + crate::scene::model::object::PropValue::ParamsVisibilityToggle(_) + ))); + } + + /// End-to-end through the actual `Message` handlers a Properties-panel + /// click/keystroke would fire — not `apply_named_parameter_editor_rows` + /// directly — covering the whole Parameters-section lifecycle: add, + /// rename, redefine, and confirm a referencing constraint re-solves + /// after each commit (not just after a whole-table Apply, since this + /// path commits per field). + #[test] + fn properties_panel_parameter_row_add_rename_redefine_and_delete() { + use crate::ui::window::named_parameters::ParamField; + + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let line = add_line(&mut app, 0.0, 0.0, 10.0, 0.0); + let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { + kind: ConstraintKind::Distance, + refs: vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], + driving_param: Some(crate::scene::named_parameters::DrivingValue::Named( + "len".to_string(), + )), + label: "Distance constraint", + }); + + // Add: the first row is auto-named "param1". + let _ = app.update(Message::PropParamAddNew); + assert!(app.tabs[app.active_tab] + .scene + .named_parameters() + .contains("param1")); + let index = app.tabs[app.active_tab] + .scene + .named_parameters() + .iter() + .position(|p| p.name == "param1") + .unwrap(); + + // Rename param1 -> len (commit-on-submit, not per keystroke: input + // alone must not touch the table yet). + let _ = app.update(Message::PropParamInput { + index, + field: ParamField::Name, + value: "len".to_string(), + }); + assert!( + app.tabs[app.active_tab] + .scene + .named_parameters() + .contains("param1"), + "typing alone must not commit" + ); + let _ = app.update(Message::PropParamCommit { + index, + field: ParamField::Name, + }); + assert!(!app.tabs[app.active_tab] + .scene + .named_parameters() + .contains("param1")); + assert!(app.tabs[app.active_tab] + .scene + .named_parameters() + .contains("len")); + + // Redefine its formula to 8 and confirm the Distance constraint + // (already referencing "len" by name, defined before this) re-solves. + let _ = app.update(Message::PropParamInput { + index, + field: ParamField::Formula, + value: "8".to_string(), + }); + let _ = app.update(Message::PropParamCommit { + index, + field: ParamField::Formula, + }); + assert_eq!( + app.tabs[app.active_tab] + .scene + .named_parameters() + .resolve("len"), + Ok(8.0) + ); + let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) { + Some(acadrust::EntityType::Line(l)) => (l.start, l.end), + other => panic!("expected a Line, got {other:?}"), + }; + let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); + assert!( + (len - 8.0).abs() < 1e-6, + "length should track the redefined parameter, got {len}" + ); + + // Delete: the row is gone from the table. + let _ = app.update(Message::PropParamDelete(index)); + assert!(!app.tabs[app.active_tab] + .scene + .named_parameters() + .contains("len")); + } + + /// A Constraints-section row click selects every entity the constraint + /// references, replacing whatever was selected before. + #[test] + fn properties_panel_constraint_link_click_selects_its_entities() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let a = add_line(&mut app, 0.0, 0.0, 10.0, 0.0); + let b = add_line(&mut app, 0.0, 5.0, 10.0, 5.0); + let unrelated = add_line(&mut app, 20.0, 20.0, 30.0, 20.0); + let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { + kind: ConstraintKind::Parallel, + refs: vec![ParametricRef::whole(a), ParametricRef::whole(b)], + driving_param: None, + label: "Parallel constraint", + }); + app.tabs[app.active_tab] + .scene + .select_entity(unrelated, true); + + let _ = app.update(Message::PropConstraintLinkClick(vec![a, b])); + + let selected: std::collections::HashSet = app.tabs[app.active_tab] + .scene + .selected_entities() + .into_iter() + .map(|(h, _)| h) + .collect(); + assert_eq!(selected, std::collections::HashSet::from([a, b])); + } + + /// A row that fails to validate (here, a formula that doesn't parse) + /// must not silently vanish or corrupt the table: it's reported as an + /// error and simply isn't added, while a valid row alongside it still + /// commits — the same isolate-the-failure treatment + /// `ParameterTable::resolve_all` already gives a bad reference. + #[test] + fn applying_a_row_with_a_malformed_formula_reports_an_error_and_keeps_the_good_row() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + + app.named_parameter_editor_rows = vec![ + crate::ui::window::named_parameters::ParamEditorRow { + name: "good".to_string(), + formula: "10".to_string(), + }, + crate::ui::window::named_parameters::ParamEditorRow { + name: "bad".to_string(), + formula: "1 +".to_string(), + }, + ]; + app.apply_named_parameter_editor_rows(); + + assert_eq!( + app.tabs[app.active_tab] + .scene + .named_parameters() + .resolve("good"), + Ok(10.0) + ); + assert!( + !app.tabs[app.active_tab] + .scene + .named_parameters() + .contains("bad"), + "a malformed row must not be added" + ); + assert!( + app.command_line + .history + .iter() + .any(|e| e.kind == crate::ui::command_line::EntryKind::Error), + "a malformed row must be reported as an error, not silently dropped" + ); + } + + /// Two rows typed with the same name must not silently collapse into + /// "whichever `set` call happens last wins" — both are refused, and an + /// unrelated row still commits normally. + #[test] + fn applying_rows_with_a_duplicate_name_refuses_both_and_keeps_the_unrelated_row() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + + app.named_parameter_editor_rows = vec![ + crate::ui::window::named_parameters::ParamEditorRow { + name: "x".to_string(), + formula: "1".to_string(), + }, + crate::ui::window::named_parameters::ParamEditorRow { + name: "x".to_string(), + formula: "2".to_string(), + }, + crate::ui::window::named_parameters::ParamEditorRow { + name: "y".to_string(), + formula: "3".to_string(), + }, + ]; + app.apply_named_parameter_editor_rows(); + + assert!( + !app.tabs[app.active_tab] + .scene + .named_parameters() + .contains("x"), + "a duplicated name must not be added at all" + ); + assert_eq!( + app.tabs[app.active_tab] + .scene + .named_parameters() + .resolve("y"), + Ok(3.0) + ); + assert!(app + .command_line + .history + .iter() + .any(|e| e.kind == crate::ui::command_line::EntryKind::Error)); + } + + /// Two endpoint picks resolve to a Coincident constraint. + #[test] + fn coincident_command_resolves_two_picked_points_into_a_constraint() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let a = add_line(&mut app, 0.0, 0.0, 5.0, 0.0); + let b = add_line(&mut app, 20.0, 0.0, 20.0, 5.0); // deliberately far from `a`, so the two picks are unambiguous + + let _ = app.apply_cmd_result(CmdResult::AddCoincidentConstraint { + first: crate::command::CoincidentPick { + handle: Some(a), + point: glam::DVec3::new(5.0, 0.0, 0.0), + whole_curve: false, + }, + second: crate::command::CoincidentPick { + handle: Some(b), + point: glam::DVec3::new(20.0, 0.0, 0.0), + whole_curve: false, + }, + multiple: false, + label: "Coincident constraint", + }); + + let set = app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .expect("a constraint set should exist"); + assert_eq!(set.constraints.len(), 1); + let c = &set.constraints[0]; + assert_eq!(c.kind, ConstraintKind::Coincident); + let entities: Vec = c.refs.iter().map(|r| r.entity).collect(); + assert!( + entities.contains(&a) && entities.contains(&b), + "the constraint should reference both lines" + ); + let Some(acadrust::EntityType::Line(first)) = + app.tabs[app.active_tab].scene.document.get_entity(a) + else { + panic!("expected first line"); + }; + let Some(acadrust::EntityType::Line(second)) = + app.tabs[app.active_tab].scene.document.get_entity(b) + else { + panic!("expected second line"); + }; + assert!( + (first.end - acadrust::types::Vector3::new(5.0, 0.0, 0.0)).length() < 1.0e-7 + ); + assert!((second.start - first.end).length() < 1.0e-7); + assert!((second.length() - 5.0).abs() < 1.0e-7); + } + + /// A pick that doesn't land near any real point (no object snap match) + /// must not add a constraint — it should report an error and leave the + /// scope untouched rather than silently constraining the wrong thing. + #[test] + fn coincident_command_reports_an_error_when_a_pick_misses() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let _a = add_line(&mut app, 0.0, 0.0, 5.0, 0.0); + + let _ = app.apply_cmd_result(CmdResult::AddCoincidentConstraint { + first: crate::command::CoincidentPick { + handle: Some(_a), + point: glam::DVec3::new(5.0, 0.0, 0.0), + whole_curve: false, + }, + second: crate::command::CoincidentPick { + handle: None, + point: glam::DVec3::new(500.0, 500.0, 0.0), + whole_curve: false, + }, + multiple: false, + label: "Coincident constraint", + }); + + assert_eq!( + app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .map(|s| s.constraints.len()) + .unwrap_or(0), + 0, + "a missed pick must not add a constraint" + ); + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod delobj_tests { + use super::super::*; + use acadrust::types::Vector3; + + fn sweep_source_presence(value: i16, mode: crate::command::ExtrudeMode) -> (bool, bool) { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + let mut circle = acadrust::Circle::new(); + circle.radius = 2.0; + let profile = app.tabs[i] + .scene + .add_entity(acadrust::EntityType::Circle(circle)); + let path = + app.tabs[i] + .scene + .add_entity(acadrust::EntityType::Line(acadrust::Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(0.0, 0.0, 5.0), + ))); + app.delete_objects = value; + + let _ = app.apply_cmd_result(CmdResult::SweepEntities { + handles: vec![profile], + path_handle: path, + mode, + options: crate::command::SweepOptions::default(), + color: [1.0; 4], + }); + + let created = app.tabs[i] + .scene + .document + .entities() + .any(|entity| match mode { + crate::command::ExtrudeMode::Solid => { + matches!(entity, acadrust::EntityType::Solid3D(_)) + } + crate::command::ExtrudeMode::Surface => { + matches!(entity, acadrust::EntityType::Surface(_)) + } + }); + assert!(created, "SWEEP must produce the requested result"); + ( + app.tabs[i].scene.document.get_entity(profile).is_some(), + app.tabs[i].scene.document.get_entity(path).is_some(), + ) + } + + #[test] + fn sweep_applies_all_four_source_deletion_modes() { + let solid_expectations = [(true, true), (false, true), (false, false), (false, false)]; + for (value, expected) in solid_expectations.into_iter().enumerate() { + assert_eq!( + sweep_source_presence(value as i16, crate::command::ExtrudeMode::Solid), + expected, + ); + } + assert_eq!( + sweep_source_presence(2, crate::command::ExtrudeMode::Surface), + (false, false), + ); + assert_eq!( + sweep_source_presence(3, crate::command::ExtrudeMode::Surface), + (false, true), + ); + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod thicken_tests { + use super::super::*; + use acadrust::entities::{Surface, SurfaceKind}; + use cadkernel::geom2d::{Circle, Curve, NurbsCurve}; + use cadkernel::space::{Parameterization, Plane}; + + #[test] + fn thicken_preserves_sources_and_round_trips_results_with_one_undo() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let body = cadkernel::brep::planar_region( + Plane::XY, + &[vec![Curve::Circle(Circle { + centre: [0.0; 2], + radius: 3.0, + })]], + ) + .unwrap(); + let source = app.add_surface_model( + acadrust::EntityType::Surface(Surface::new(SurfaceKind::Plane)), + body, + ); + assert!(!source.is_null()); + let i = app.active_tab; + let invalid = app.tabs[i] + .scene + .add_entity(acadrust::EntityType::Surface(Surface::new( + SurfaceKind::Generic, + ))); + let original = + serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(); + let before = app.tabs[i].history.undo_stack.len(); + let _ = app.apply_cmd_result(CmdResult::ThickenEntities { + handles: vec![source], + distance: 0.0, + }); + assert_eq!(app.tabs[i].history.undo_stack.len(), before); + let _ = app.apply_cmd_result(CmdResult::ThickenEntities { + handles: vec![invalid, source], + distance: 2.0, + }); + assert_eq!(app.tabs[i].history.undo_stack.len(), before + 1); + let result = app.tabs[i] + .scene + .document + .entities() + .find_map(|entity| { + matches!(entity, acadrust::EntityType::Solid3D(_)).then_some(entity.common().handle) + }) + .unwrap(); + assert!(app.tabs[i] + .scene + .document + .solid_history_operation(result) + .is_some()); + assert_eq!( + serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(), + original + ); + let expected = + cadkernel::brep::analytic_mass_properties(&app.tabs[i].scene.solid_models[&result]) + .unwrap() + .volume; + let bytes = crate::io::save_to_bytes( + &app.tabs[i].scene.document, + "dwg", + app.tabs[i].scene.document.version, + ) + .unwrap(); + let document = crate::io::load_bytes("thicken.dwg", bytes).unwrap(); + let mut restored = crate::scene::Scene::new(); + restored.document = document; + restored.restore_solid_models(&[result]); + let actual = cadkernel::brep::analytic_mass_properties(&restored.solid_models[&result]) + .unwrap() + .volume; + assert!((expected - actual).abs() < 1e-8); + app.undo_active_tab(); + assert!(app.tabs[i].scene.document.get_entity(result).is_none()); + assert!(app.tabs[i] + .scene + .document + .solid_history_operation(result) + .is_none()); + assert!(app.tabs[i].scene.document.get_entity(source).is_some()); + assert!(app.tabs[i].scene.document.get_entity(invalid).is_some()); + app.redo_active_tab(); + assert!(app.tabs[i].scene.document.get_entity(result).is_some()); + assert!(app.tabs[i] + .scene + .document + .solid_history_operation(result) + .is_some()); + assert_eq!( + serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(), + original + ); + } + + #[test] + fn thicken_creates_a_solid_from_a_free_form_surface() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let profile = NurbsCurve::interpolate( + &[[0.0, 0.0], [0.7, 0.15], [1.3, -0.1], [2.0, 0.0]], + None, + None, + Parameterization::Chord, + ) + .unwrap(); + let body = + cadkernel::brep::extrude_surface(Plane::XY, &[Curve::Nurbs(profile)], [0.0, 0.0, 2.0]) + .unwrap(); + let source = app.add_surface_model( + acadrust::EntityType::Surface(Surface::new(SurfaceKind::Generic)), + body, + ); + let i = app.active_tab; + + let _ = app.apply_cmd_result(CmdResult::ThickenEntities { + handles: vec![source], + distance: 0.15, + }); + + let solids = app.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + matches!(entity, acadrust::EntityType::Solid3D(_)).then_some(entity.common().handle) + }) + .collect::>(); + assert_eq!(solids.len(), 1); + let solid = &app.tabs[i].scene.solid_models[&solids[0]]; + assert!(solid.validate().is_empty()); + assert!(solid.edges.iter().all(|(_, edge)| edge.coedges.len() == 2)); + assert!(app.tabs[i] + .scene + .document + .solid_history_operation(solids[0]) + .is_some()); + assert!(app.tabs[i].scene.document.get_entity(source).is_some()); + } +} + +#[cfg(test)] +mod dispatched_command_task_tests { + use super::super::*; + use crate::command::CmdResult; + use acadrust::entities::ViewportRenderMode as Mode; + + /// `CmdResult::Dispatch` and `CmdResult::Relaunch` run the assembled line + /// through `dispatch_command`, and the Task it returns must reach the + /// runtime: the render mode a VSCURRENT keyword picker chooses is applied + /// by a message that Task carries. + #[test] + fn dispatch_and_relaunch_keep_the_task_the_command_returns() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + + app.tabs[i].render_mode = Mode::Wireframe2D; + let task = app.apply_cmd_result(CmdResult::Dispatch("VSCURRENT GOURAUDSHADED".into())); + app.drive_headless_task(task).unwrap(); + assert_eq!( + app.tabs[i].render_mode, + Mode::GouraudShaded, + "Dispatch dropped the dispatched command's task" + ); + + app.tabs[i].render_mode = Mode::Wireframe2D; + let task = app.apply_cmd_result(CmdResult::Relaunch( + "VSCURRENT GOURAUDSHADED".into(), + Vec::new(), + )); + app.drive_headless_task(task).unwrap(); + assert_eq!( + app.tabs[i].render_mode, + Mode::GouraudShaded, + "Relaunch dropped the dispatched command's task" + ); + } +} diff --git a/src/app/command_driver/text_edit.rs b/src/app/command_driver/text_edit.rs new file mode 100644 index 000000000..c121fec42 --- /dev/null +++ b/src/app/command_driver/text_edit.rs @@ -0,0 +1,245 @@ +use super::*; + +impl OpenCADStudio { + pub(super) fn handle_open_mtext_editor(&mut self, result: CmdResult) { + let i = self.active_tab; + let CmdResult::OpenMTextEditor { + pos, + handle, + initial, + height, + template, + } = result + else { + unreachable!("router only routes the matching variant"); + }; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.open_mtext_editor(pos, handle, &initial, height, template.map(|m| *m)); + } + + pub(super) fn handle_suspend_for_mtext_input(&mut self, pos: glam::DVec3, initial: String, height: f64) { + let i = self.active_tab; + self.tabs[i].suspended_cmd = self.tabs[i].active_cmd.take(); + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + self.command_mtext_input = true; + self.pending_command_editor_text = None; + self.open_mtext_editor(pos, None, &initial, height, None); + } + + pub(super) fn handle_open_text_editor(&mut self, result: CmdResult) { + let i = self.active_tab; + let CmdResult::OpenTextEditor { + pos, + handle, + initial, + height, + } = result + else { + unreachable!("router only routes the matching variant"); + }; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.open_text_inline( + pos, + handle, + &initial, + height, + super::super::text_inline::TextEntityField::Text, + None, + ); + } + + pub(super) fn handle_suspend_for_text_input( + &mut self, + pos: glam::DVec3, + entity: acadrust::entities::Text, + ) { + let i = self.active_tab; + self.tabs[i].suspended_cmd = self.tabs[i].active_cmd.take(); + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + let height = entity.height; + self.open_text_inline( + pos, + None, + "", + height, + super::super::text_inline::TextEntityField::Text, + Some(entity), + ); + } + + pub(super) fn handle_edit_text_entity(&mut self, handle: Handle) -> Task { + let i = self.active_tab; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + self.ribbon.deactivate_tool(); + self.begin_text_edit(handle) + } + + pub(super) fn handle_suspend_for_text_edit(&mut self, handle: Handle) -> Task { + let i = self.active_tab; + let is_editable = + crate::app::text_inline::can_edit_text(handle, &self.tabs[i].scene.document); + if !is_editable { + self.command_line + .push_error(crate::t!("TEXTEDIT: selected entity is not text.").as_ref()); + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + return Task::none(); + } + let cmd = self.tabs[i].active_cmd.take(); + self.tabs[i].suspended_cmd = cmd; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + self.ribbon.deactivate_tool(); + self.begin_text_edit(handle) + } + + pub(super) fn handle_set_textedit_mode(&mut self, val: bool) { + let i = self.active_tab; + self.texteditmode = val; + let display_val = if val { 1 } else { 0 }; + self.command_line + .push_output(crate::tf!("TEXTEDITMODE set to {display_val}").as_ref()); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + } + + pub(super) fn handle_edit_dimensions( + &mut self, + mut handles: Vec, + operation: crate::command::DimensionEditOperation, + ) { + let i = self.active_tab; + handles.retain(|handle| { + !self.tabs[i].scene.is_layer_locked(*handle) + && matches!( + self.tabs[i].scene.document.get_entity(*handle), + Some(acadrust::EntityType::Dimension(_)) + ) + }); + if handles.is_empty() { + self.command_line + .push_error(crate::t!("DIMEDIT: no editable dimensions were selected.").as_ref()); + } else { + self.push_undo_snapshot(i, "DIMEDIT"); + for handle in &handles { + if let Some(acadrust::EntityType::Dimension(dimension)) = + self.tabs[i].scene.document.get_entity_mut(*handle) + { + use crate::command::DimensionEditOperation as Operation; + match &operation { + Operation::Home => { + let base = dimension.base_mut(); + base.text_middle_point = acadrust::types::Vector3::ZERO; + base.insertion_point = acadrust::types::Vector3::ZERO; + base.text_user_positioned = false; + base.text_rotation = 0.0; + } + Operation::NewText(text) => { + dimension + .base_mut() + .set_text_override((!text.is_empty()).then_some(text.clone())); + } + Operation::Rotate(degrees) => { + dimension.base_mut().text_rotation = degrees.to_radians(); + } + Operation::Oblique(degrees) => match dimension { + acadrust::entities::Dimension::Linear(value) => { + value.ext_line_rotation = degrees.to_radians(); + } + acadrust::entities::Dimension::Aligned(value) => { + value.ext_line_rotation = degrees.to_radians(); + } + _ => {} + }, + } + } + self.tabs[i].scene.invalidate_dim_block_recorded(*handle); + } + let changes: Vec<_> = handles + .iter() + .copied() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].dirty = true; + self.command_line.push_output( + crate::tf!("DIMEDIT: updated {} dimension(s).", handles.len()).as_ref(), + ); + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + } + + pub(super) fn handle_ddedit_entity(&mut self, handle: Handle, new_text: String) -> Option> { + let i = self.active_tab; + if self.reject_locked_edit(i, handle) { + self.tabs[i].active_cmd = None; + return Some(Task::none()); + } + self.push_undo_snapshot(i, "DDEDIT"); + let mut updated = false; + let mut is_dim = false; + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { + match entity { + acadrust::EntityType::Text(t) => { + t.value = new_text; + updated = true; + } + acadrust::EntityType::MText(t) => { + t.value = new_text; + updated = true; + } + acadrust::EntityType::AttributeDefinition(a) => { + a.default_value = new_text; + updated = true; + } + acadrust::EntityType::AttributeEntity(a) => { + a.set_value(new_text); + updated = true; + } + acadrust::EntityType::Dimension(d) => { + // Empty string resets to auto-measured value; otherwise set override. + let base = d.base_mut(); + base.text = new_text; + updated = true; + is_dim = true; + } + _ => {} + } + } + if is_dim { + // The edited override changed the dimension text; drop its + // stale *D block so save re-bakes it. (#181) + self.tabs[i].scene.invalidate_dim_block_recorded(handle); + } + if updated { + self.tabs[i].dirty = true; + self.command_line + .push_output(crate::t!("DDEDIT: text updated.").as_ref()); + } else { + self.discard_last_undo_entry(i); + self.command_line + .push_error(crate::t!("DDEDIT: entity type not supported.").as_ref()); + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + None + } +} diff --git a/src/app/command_driver/transform.rs b/src/app/command_driver/transform.rs new file mode 100644 index 000000000..a9fb68786 --- /dev/null +++ b/src/app/command_driver/transform.rs @@ -0,0 +1,577 @@ +use super::*; + +impl OpenCADStudio { + pub(super) fn handle_transform_selected(&mut self, mut handles: Vec, transform: crate::command::EntityTransform) -> Option> { + let i = self.active_tab; + handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + if handles.is_empty() { + self.tabs[i].active_cmd = None; + return Some(Task::none()); + } + if matches!(&transform, crate::command::EntityTransform::Translate(_)) { + let scope = self.tabs[i].current_parametric_scope(); + handles = self.tabs[i] + .scene + .parametric_connected_handles(scope, &handles, false); + handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + } + let label = self.history_label_from_active_cmd(i, "MOVE"); + // A translation carries its complete connected constraint + // component so each member keeps its own dimensions. Other + // transforms retain their selected-object behavior. + let pending = self.begin_undo(i, label, handles.len(), true); + self.tabs[i].scene.transform_entities(&handles, &transform); + self.apply_inferred_constraints(i, &handles); + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + self.refresh_properties(); + if let Some(p) = pending { + self.commit_undo_delta(i, p); + } + None + } + + pub(super) fn handle_copy_selected(&mut self, mut handles: Vec, transform: crate::command::EntityTransform) -> Option> { + let i = self.active_tab; + handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + if handles.is_empty() { + return Some(Task::none()); + } + let label = self.history_label_from_active_cmd(i, "COPY"); + // Copying a dimension clones a *D block record, so gate delta on + // the selection being dimension-free. + let delta_safe = self.delta_copy_safe(i, &handles); + let pending = self.begin_undo(i, label, handles.len(), delta_safe); + let new_handles = self.tabs[i].scene.copy_entities(&handles, &transform); + self.tabs[i].dirty = true; + self.tabs[i].scene.deselect_all(); + for h in new_handles { + self.tabs[i].scene.select_entity(h, false); + } + self.tabs[i].scene.clear_preview_wire(); + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + None + } + + pub(super) fn handle_align_selected(&mut self, result: CmdResult) { + let i = self.active_tab; + let CmdResult::AlignSelected { + mut handles, + src1, + dst1, + angle_rad, + scale, + } = result + else { + unreachable!("router only routes the matching variant"); + }; + handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + if handles.is_empty() { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + } else { + let label = self.history_label_from_active_cmd(i, "ALIGN"); + // A chain of transform_entities on the same handles — the + // recording keeps each entity's first (pre-align) image, so + // it's delta-safe like a single transform. + let pending = self.begin_undo(i, label, handles.len(), true); + // Step 1: translate so src1 is at origin + self.tabs[i].scene.transform_entities( + &handles, + &crate::command::EntityTransform::Translate(-src1), + ); + // Step 2: uniform scale (only when != 1) + if (scale - 1.0).abs() > 1e-4 { + self.tabs[i].scene.transform_entities( + &handles, + &crate::command::EntityTransform::Scale { + center: glam::DVec3::ZERO, + factor: scale, + }, + ); + } + // Step 3: rotate in the XY plane by angle_rad + if angle_rad.abs() > 1e-4 { + self.tabs[i].scene.transform_entities( + &handles, + &crate::command::EntityTransform::Rotate { + center: glam::DVec3::ZERO, + axis: glam::DVec3::Z, + angle_rad, + }, + ); + } + // Step 4: translate to dst1 + self.tabs[i].scene.transform_entities( + &handles, + &crate::command::EntityTransform::Translate(dst1), + ); + self.tabs[i].dirty = true; + self.tabs[i].scene.deselect_all(); + for h in &handles { + self.tabs[i].scene.select_entity(*h, false); + } + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + self.command_line + .push_output(crate::t!("ALIGN: applied.").as_ref()); + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + } + + pub(super) fn handle_stretch_window(&mut self, mut handles: Vec, windows: Vec<(glam::DVec3, glam::DVec3)>) { + let i = self.active_tab; + // Accumulate every entity touched by any crossing window. Keep STRETCH + // in its selection stage; Enter is what advances to the base point. + { + let scene = &self.tabs[i].scene; + + for (win_min, win_max) in &windows { + handles.extend( + scene + .interaction_handles_in_world_aabb([ + win_min.x, win_min.y, win_max.x, win_max.y, + ]) + .into_iter() + .filter(|&handle| !scene.is_layer_locked(handle)), + ); + } + } + + handles.sort_unstable_by_key(|handle| handle.value()); + handles.dedup(); + + if handles.is_empty() { + self.command_line + .push_output(crate::t!("STRETCH: nothing crosses the window.").as_ref()); + } else { + // Visual feedback must only highlight entities that actually have a + // stretchable point inside one of the crossing windows. `handles` can + // contain the whole candidate/preselection set, while `windows` decides + // which vertices will really move. + let point_inside = |point: glam::DVec3| { + const EPS: f64 = 1.0e-9; + + windows.iter().any(|(win_min, win_max)| { + point.x >= win_min.x - EPS + && point.x <= win_max.x + EPS + && point.y >= win_min.y - EPS + && point.y <= win_max.y + EPS + && point.z >= win_min.z - EPS + && point.z <= win_max.z + EPS + }) + }; + + let mut visual_handles = Vec::new(); + + for &handle in &handles { + let wires = self.tabs[i].scene.wire_models_for(&[handle]); + + let affected = wires.iter().any(|wire| { + // LINE / LWPOLYLINE / other vertex-based entities. + let vertex_hit = wire + .key_vertices + .iter() + .any(|point| point_inside(glam::DVec3::from_array(*point))); + + if vertex_hit { + return true; + } + + // Whole-object STRETCH cases such as circles/arcs/inserts/points: + // use only their meaningful anchor snap, not quadrants or + // tessellated display points. + wire.snap_pts.iter().any(|(point, hint)| { + matches!( + hint, + crate::scene::model::wire_model::SnapHint::Center + | crate::scene::model::wire_model::SnapHint::Insertion + | crate::scene::model::wire_model::SnapHint::Node + ) && point_inside(*point) + }) + }); + + if affected { + visual_handles.push(handle); + } + } + + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.select_entities(&visual_handles); + self.refresh_selected_grips(); + // STRETCH temporary grips: keep only the actual vertices/endpoints + // that lie inside one of the accumulated crossing windows. + // + // Mid-segment grips are deliberately excluded: STRETCH moves vertices, + // not the midpoint affordances used by normal grip editing. + let mut stretch_grips = Vec::new(); + let mut stretch_grip_handles = Vec::new(); + + for (handle, grip) in std::mem::take(&mut self.tabs[i].selected_grip_handles) + .into_iter() + .zip(std::mem::take(&mut self.tabs[i].selected_grips)) + { + if !grip.is_midpoint && point_inside(grip.world) { + stretch_grip_handles.push(handle); + stretch_grips.push(grip); + } + } + + self.tabs[i].selected_grip_handles = stretch_grip_handles; + self.tabs[i].selected_grips = stretch_grips; + } + + use crate::command::CadCommand; + use crate::modules::draw::modify::stretch::StretchCommand; + + let wires = self.tabs[i].scene.wire_models_for(&handles); + + let cmd = StretchCommand::with_windows(handles, wires, windows); + + self.command_line.push_info(&CadCommand::prompt(&cmd)); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + } + + pub(super) fn handle_stretch_entities(&mut self, mut handles: Vec, windows: Vec<(glam::DVec3, glam::DVec3)>, delta: glam::DVec3) -> Option> { + let i = self.active_tab; + handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + if handles.is_empty() { + self.tabs[i].active_cmd = None; + return Some(Task::none()); + } + let structural = handles.iter().any(|handle| { + matches!( + self.tabs[i].scene.document.get_entity(*handle), + Some(acadrust::EntityType::Dimension(_)) + ) + }); + let pending = self.begin_undo(i, "STRETCH", handles.len(), !structural); + let mut count = 0usize; + let mut changed_handles = Vec::new(); + let mut driven_refs = Vec::new(); + + // Helper: is DXF point (x, y) inside the world-space window? + // Drawing plane is world XY (= DXF XY). + let in_win = |x: f64, y: f64| -> bool { + windows.iter().any(|(win_min, win_max)| { + x >= win_min.x && x <= win_max.x && y >= win_min.y && y <= win_max.y + }) + }; + + let dx = delta.x as f64; + let dy = delta.y as f64; // drawing plane is world XY + let dz = delta.z as f64; + + // Dimensions whose points moved — their baked *D block is + // stale afterwards and must be dropped (see #398 / #372). + let mut stretched_dims: Vec = Vec::new(); + for handle in &handles { + let before = self.tabs[i].scene.document.get_entity_arc(*handle); + if let Some(entity) = before.as_deref() { + use crate::scene::parametric_constraints::ParametricRef; + match entity { + acadrust::EntityType::Line(line) => { + if in_win(line.start.x, line.start.y) { + driven_refs.push(ParametricRef::point(*handle, 0)); + } + if in_win(line.end.x, line.end.y) { + driven_refs.push(ParametricRef::point(*handle, 1)); + } + } + acadrust::EntityType::LwPolyline(polyline) => { + if let Some(world) = + crate::entities::curve::lwpolyline_world_xy(polyline) + { + for (index, vertex) in world.vertices.iter().enumerate() { + if in_win(vertex.location.x, vertex.location.y) { + driven_refs.push(ParametricRef::point( + *handle, + index as i32, + )); + } + } + } + } + acadrust::EntityType::Polyline2D(polyline) => { + for (index, vertex) in polyline.vertices.iter().enumerate() { + if in_win(vertex.location.x, vertex.location.y) { + driven_refs.push(ParametricRef::point( + *handle, + index as i32, + )); + } + } + } + acadrust::EntityType::Circle(circle) + if in_win(circle.center.x, circle.center.y) => + { + driven_refs.push(ParametricRef::center(*handle)); + } + acadrust::EntityType::Arc(arc) + if in_win(arc.center.x, arc.center.y) => + { + driven_refs.push(ParametricRef::center(*handle)); + } + _ => {} + } + } + let Some(entity) = self.tabs[i].scene.document.get_entity_mut(*handle) else { + continue; + }; + let mut stretched = false; + match entity { + acadrust::EntityType::Line(l) => { + let s_in = in_win(l.start.x, l.start.y); + let e_in = in_win(l.end.x, l.end.y); + if s_in { + l.start.x += dx; + l.start.y += dy; + l.start.z += dz; + stretched = true; + } + if e_in { + l.end.x += dx; + l.end.y += dy; + l.end.z += dz; + stretched = true; + } + } + acadrust::EntityType::LwPolyline(p) => { + let Some(mut world) = crate::entities::curve::lwpolyline_world_xy(p) + else { + continue; + }; + for v in &mut world.vertices { + if in_win(v.location.x, v.location.y) { + v.location.x += dx; + v.location.y += dy; + stretched = true; + } + } + if stretched { + *p = world; + } + } + acadrust::EntityType::Polyline2D(p) => { + for v in &mut p.vertices { + if in_win(v.location.x, v.location.y) { + v.location.x += dx; + v.location.y += dy; + stretched = true; + } + } + } + acadrust::EntityType::Polyline(p) => { + for v in &mut p.vertices { + if in_win(v.location.x, v.location.z) { + v.location.x += dx; + v.location.z += dy; + stretched = true; + } + } + } + acadrust::EntityType::Arc(a) => { + if in_win(a.center.x, a.center.y) { + a.center.x += dx; + a.center.y += dy; + a.center.z += dz; + stretched = true; + } + } + acadrust::EntityType::Circle(c) => { + if in_win(c.center.x, c.center.y) { + c.center.x += dx; + c.center.y += dy; + c.center.z += dz; + stretched = true; + } + } + acadrust::EntityType::Ellipse(e) => { + if in_win(e.center.x, e.center.y) { + e.center.x += dx; + e.center.y += dy; + e.center.z += dz; + stretched = true; + } + } + acadrust::EntityType::Insert(ins) => { + if in_win(ins.insert_point.x, ins.insert_point.y) { + ins.insert_point.x += dx; + ins.insert_point.y += dy; + ins.insert_point.z += dz; + stretched = true; + } + } + acadrust::EntityType::Text(t) => { + if in_win(t.insertion_point.x, t.insertion_point.y) { + t.insertion_point.x += dx; + t.insertion_point.y += dy; + t.insertion_point.z += dz; + stretched = true; + } + } + acadrust::EntityType::MText(t) => { + if in_win(t.insertion_point.x, t.insertion_point.y) { + t.insertion_point.x += dx; + t.insertion_point.y += dy; + t.insertion_point.z += dz; + stretched = true; + } + } + acadrust::EntityType::Viewport(vp) => { + stretched = windows.iter().any(|(win_min, win_max)| { + crate::entities::viewport::stretch(vp, *win_min, *win_max, delta) + }); + } + acadrust::EntityType::Dimension(dim) => { + use acadrust::entities::Dimension; + // Move every definition point that falls inside the + // window — the same points the grips expose — then + // refresh the stored measurement so the value tracks + // the stretched geometry. + let mv = |p: &mut acadrust::types::Vector3| { + if in_win(p.x, p.y) { + p.x += dx; + p.y += dy; + p.z += dz; + true + } else { + false + } + }; + match dim { + Dimension::Linear(d) => { + stretched |= mv(&mut d.first_point); + stretched |= mv(&mut d.second_point); + stretched |= mv(&mut d.definition_point); + } + Dimension::Aligned(d) => { + stretched |= mv(&mut d.first_point); + stretched |= mv(&mut d.second_point); + stretched |= mv(&mut d.definition_point); + } + Dimension::Radius(d) => { + stretched |= mv(&mut d.angle_vertex); + stretched |= mv(&mut d.definition_point); + } + Dimension::Diameter(d) => { + stretched |= mv(&mut d.angle_vertex); + stretched |= mv(&mut d.definition_point); + } + Dimension::Angular2Ln(d) => { + stretched |= mv(&mut d.angle_vertex); + stretched |= mv(&mut d.first_point); + stretched |= mv(&mut d.second_point); + stretched |= mv(&mut d.definition_point); + } + Dimension::Angular3Pt(d) => { + stretched |= mv(&mut d.angle_vertex); + stretched |= mv(&mut d.first_point); + stretched |= mv(&mut d.second_point); + stretched |= mv(&mut d.definition_point); + } + Dimension::Ordinate(d) => { + stretched |= mv(&mut d.feature_location); + stretched |= mv(&mut d.leader_endpoint); + } + Dimension::Arc(d) => { + stretched |= mv(&mut d.definition_point); + stretched |= mv(&mut d.first_extension_point); + stretched |= mv(&mut d.second_extension_point); + stretched |= mv(&mut d.center_point); + if d.has_leader { + stretched |= mv(&mut d.first_leader_point); + stretched |= mv(&mut d.second_leader_point); + } + } + Dimension::LargeRadial(d) => { + stretched |= mv(&mut d.definition_point); + stretched |= mv(&mut d.chord_point); + stretched |= mv(&mut d.override_center); + stretched |= mv(&mut d.jog_point); + } + } + // Pinned text follows too; the zero sentinel means + // "auto placement" and must not be captured by a + // window that happens to cover the origin. + let t = dim.base().text_middle_point; + if t.x * t.x + t.y * t.y + t.z * t.z > 1e-16 { + let mut t = t; + if mv(&mut t) { + dim.base_mut().text_middle_point = t; + stretched = true; + } + } + if stretched { + dim.base_mut().actual_measurement = dim.measurement(); + stretched_dims.push(*handle); + } + } + _ => { + // Generic: move entire entity (treat as block-level) + stretched = false; // skip generic types + } + } + if stretched { + if let Some(before) = before { + self.tabs[i].scene.record_undo_before(*handle, Some(before)); + } + self.tabs[i].scene.mark_entity_dirty(*handle); + changed_handles.push(*handle); + count += 1; + } + } + // A stretched dimension renders through its baked *D block when + // one exists (file roundtrip) — drop it so tessellation falls + // back to the live points and the next save re-bakes. (#372) + for h in stretched_dims { + self.tabs[i].scene.invalidate_dim_block_recorded(h); + } + + // Geometry was edited in place via get_entity_mut, which the + // scene's tessellation cache doesn't observe. Re-tessellate the + // moved entities so the viewport reflects the stretch right away + // instead of only on the next unrelated redraw. See #95. + if count > 0 { + let changes: Vec<_> = changed_handles + .iter() + .map(|&handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &driven_refs, + self.constraint_solve_mode && !driven_refs.is_empty(), + ); + self.apply_inferred_constraints(i, &changed_handles); + } + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + self.command_line + .push_output(crate::tf!("STRETCH: {count} entity(ies) stretched.").as_ref()); + self.refresh_properties(); + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + None + } +} diff --git a/src/app/command_driver/utilities.rs b/src/app/command_driver/utilities.rs new file mode 100644 index 000000000..5aff17e71 --- /dev/null +++ b/src/app/command_driver/utilities.rs @@ -0,0 +1,1050 @@ +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: &acadrust::EntityType| { + let last_grip = match entity { + acadrust::EntityType::Line(line) => { + return Some(glam::DVec3::new(line.end.x, line.end.y, line.end.z)); + } + acadrust::EntityType::Arc(_) => Some(2), + acadrust::EntityType::LwPolyline(polyline) => { + polyline.vertices.len().checked_sub(1) + } + acadrust::EntityType::Polyline(polyline) => polyline.vertices.len().checked_sub(1), + acadrust::EntityType::Polyline2D(polyline) => { + polyline.vertices.len().checked_sub(1) + } + acadrust::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.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 { + 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().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 { + 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, Vec) = 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) + } + + /// `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, bool) { + // 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); + // 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); + } + // 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); + } + } + 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( + acadrust::EntityType::Solid3D(_) + | acadrust::EntityType::Surface(_) + | acadrust::EntityType::Region(_) + | acadrust::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 result: Option = { + 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) => (self.apply_cmd_result(r), 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 { + 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 { + 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 { + 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) { + let toks: Vec = 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") + { + return self.dispatch_command(cmd); + } + 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`). + return self.dispatch_command(cmd); + } + let toks: Vec = 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 { + 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 = 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> { + 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.box_crossing = crossing; + selection.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 = 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()); + } + let count = add.len(); + for h in add { + self.tabs[i].scene.select_entity(h, false); + } + self.command_line + .push_info(crate::tf!("{count} object(s) added to selection.").as_ref()); + self.refresh_properties(); + let handles: Vec = 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 { + 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 { + let is_point_step = !self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.input_kind().wants_text()) + .unwrap_or(true) + || self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.point_step_accepts_keywords()) + .unwrap_or(false); + let not_entity_pick = !self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.needs_entity_pick()) + .unwrap_or(false); + if is_point_step && not_entity_pick { + self.start_mtp_modifier(i); + 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. + 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; + } + } +} diff --git a/src/app/command_driver/viewport.rs b/src/app/command_driver/viewport.rs new file mode 100644 index 000000000..a2da1eb67 --- /dev/null +++ b/src/app/command_driver/viewport.rs @@ -0,0 +1,414 @@ +use super::*; + +impl OpenCADStudio { + pub(super) fn handle_mview_create(&mut self, viewport: acadrust::entities::Viewport, preserve_view: bool) { + let i = self.active_tab; + let saved_view = preserve_view.then(|| { + ( + viewport.view_target.clone(), + viewport.view_direction.clone(), + viewport.view_center.clone(), + viewport.view_height, + viewport.custom_scale, + viewport.lens_length, + viewport.twist_angle, + viewport.status.perspective, + ) + }); + let label = self.history_label_from_active_cmd(i, "MVIEW"); + let pending = self.begin_undo(i, label, 1, true); + let handle = self.commit_entity_handle(acadrust::EntityType::Viewport(viewport)); + if let (Some(handle), Some(saved)) = (handle, saved_view) { + if let Some(acadrust::EntityType::Viewport(viewport)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + viewport.view_target = saved.0; + viewport.view_direction = saved.1; + viewport.view_center = saved.2; + viewport.view_height = saved.3; + viewport.custom_scale = saved.4; + viewport.lens_length = saved.5; + viewport.twist_angle = saved.6; + viewport.status.perspective = saved.7; + } + self.tabs[i].scene.camera_generation += 1; + } + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + } + + pub(super) fn handle_mview_create_clipped( + &mut self, + boundary: Option, + boundary_handle: Handle, + ) -> Option> { + let i = self.active_tab; + if boundary.is_none() { + let scene = &self.tabs[i].scene; + let valid = scene.entity_belongs_to_current_layout(boundary_handle) + && scene + .document + .get_entity(boundary_handle) + .is_some_and(|entity| match entity { + acadrust::EntityType::Circle(_) => true, + acadrust::EntityType::Ellipse(ellipse) => ellipse.is_full(), + acadrust::EntityType::LwPolyline(polyline) => polyline.is_closed, + acadrust::EntityType::Polyline(polyline) => polyline.is_closed(), + acadrust::EntityType::Polyline2D(polyline) => polyline.is_closed(), + acadrust::EntityType::Polyline3D(polyline) => polyline.flags.closed, + _ => false, + }); + if !valid { + self.command_line.push_error( + crate::t!( + "MVIEW Object: select a closed paper-space circle, ellipse, or polyline." + ) + .as_ref(), + ); + if let Some(prompt) = self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.prompt()) + { + self.command_line.push_info(&prompt); + } + return Some(Task::none()); + } + } + + let created_boundary = boundary.is_some(); + let touched = 2; + let label = self.history_label_from_active_cmd(i, "MVIEW"); + let pending = self.begin_undo(i, label, touched, true); + let clip_handle = match boundary { + Some(mut boundary) => { + // A non-rectangular viewport owns a helper boundary + // entity through `clip_boundary_handle`. Keep that + // helper in the document for DWG compatibility and + // stencil clipping, but do not expose it as a separate + // selectable polyline. + boundary.common_mut().invisible = true; + match self.commit_entity_handle(boundary) { + Some(handle) => handle, + None => { + self.tabs[i].active_cmd = None; + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + return Some(Task::none()); + } + } + } + None => boundary_handle, + }; + let polygon = self.tabs[i].scene.clip_boundary_polygon(clip_handle, 0.0); + let bounds: Option<(f64, f64, f64, f64)> = polygon.iter().fold(None, |bounds, point| { + if !point[0].is_finite() || !point[1].is_finite() { + return bounds; + } + Some(match bounds { + Some((min_x, min_y, max_x, max_y)) => ( + min_x.min(point[0] as f64), + min_y.min(point[1] as f64), + max_x.max(point[0] as f64), + max_y.max(point[1] as f64), + ), + None => ( + point[0] as f64, + point[1] as f64, + point[0] as f64, + point[1] as f64, + ), + }) + }); + let Some((min_x, min_y, max_x, max_y)) = bounds else { + self.command_line + .push_error(crate::t!("MVIEW: the clipping boundary has no usable area.").as_ref()); + self.tabs[i].active_cmd = None; + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + return Some(Task::none()); + }; + if max_x - min_x < 1e-6 || max_y - min_y < 1e-6 { + self.command_line + .push_error(crate::t!("MVIEW: the clipping boundary has no usable area.").as_ref()); + self.tabs[i].active_cmd = None; + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + return Some(Task::none()); + } + + let mut viewport = acadrust::entities::Viewport::new(); + viewport.center = + acadrust::types::Vector3::new((min_x + max_x) / 2.0, (min_y + max_y) / 2.0, 0.0); + viewport.width = max_x - min_x; + viewport.height = max_y - min_y; + viewport.id = 2; + viewport.clip_boundary_handle = clip_handle; + let viewport_handle = self.commit_entity_handle(acadrust::EntityType::Viewport(viewport)); + if let Some(viewport_handle) = viewport_handle { + if !created_boundary { + let before = self.tabs[i] + .scene + .document + .get_entity(clip_handle) + .cloned() + .map(std::sync::Arc::new); + self.tabs[i].scene.record_undo_before(clip_handle, before); + } + if let Some(boundary) = self.tabs[i].scene.document.get_entity_mut(clip_handle) { + let common = boundary.common_mut(); + common.invisible = true; + if !common.reactors.contains(&viewport_handle) { + common.reactors.push(viewport_handle); + } + } + self.tabs[i] + .scene + .bump_entities(&[(clip_handle, crate::scene::ChangeKind::Modified)]); + } + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + None + } + + pub(super) fn handle_wipeout_from_polyline( + &mut self, + handle: Handle, + erase_source: bool, + ) -> Option> { + let i = self.active_tab; + let wipeout = { + let scene = &self.tabs[i].scene; + scene + .entity_belongs_to_active_space(handle) + .then(|| scene.document.get_entity(handle)) + .flatten() + .and_then(crate::modules::draw::draw::wipeout::wipeout_from_polyline) + }; + if let Some(wipeout) = wipeout { + if erase_source { + return Some(self.apply_cmd_result(CmdResult::ReplaceMany( + vec![(handle, Vec::new())], + vec![wipeout], + ))); + } + return Some(self.apply_cmd_result(CmdResult::CommitAndExit(wipeout))); + } + self.command_line.push_error( + crate::t!("WIPEOUT Polyline: select a straight, closed, planar 2D polyline with at least 3 non-intersecting vertices.").as_ref(), + ); + let command = crate::modules::draw::draw::wipeout::WipeoutCommand::new_polyline(); + self.command_line + .push_info(&crate::command::CadCommand::prompt(&command)); + self.tabs[i].active_cmd = Some(Box::new(command)); + None + } + + pub(super) fn handle_mview_switch_layout(&mut self, layout: String) -> Task { + let i = self.active_tab; + let task = self.on_layout_switch_preserving_command(layout); + if let Some(prompt) = self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.prompt()) + { + self.command_line.push_info(&prompt); + } + task + } + + pub(super) fn handle_mview_cancel_to_layout(&mut self, layout: String) -> Task { + let i = self.active_tab; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + self.on_layout_switch(layout) + } + + pub(super) fn handle_vp_layer_update( + &mut self, + vp_handle: Handle, + freeze: Vec, + thaw: Vec, + ) { + let i = self.active_tab; + // Resolve layer names → handles, then update frozen_layers on the viewport(s). + // vp_handle == Handle::NULL means "apply to all viewports in current layout". + let freeze_handles: Vec = freeze + .iter() + .filter_map(|name| { + self.tabs[i] + .scene + .document + .layers + .iter() + .find(|l| l.name.eq_ignore_ascii_case(name)) + .map(|l| l.handle) + }) + .collect(); + let thaw_handles: Vec = thaw + .iter() + .filter_map(|name| { + self.tabs[i] + .scene + .document + .layers + .iter() + .find(|l| l.name.eq_ignore_ascii_case(name)) + .map(|l| l.handle) + }) + .collect(); + + let mut frozen_count = 0usize; + let mut thawed_count = 0usize; + + // Collect target viewport handles + let target_handles: Vec = if vp_handle == acadrust::Handle::NULL { + // All viewports in current layout block + let block_handle = self.tabs[i].scene.current_layout_block_handle_pub(); + self.tabs[i] + .scene + .document + .entities() + .filter(|e| { + e.common().owner_handle == block_handle + && matches!(e, acadrust::EntityType::Viewport(_)) + }) + .map(|e| e.common().handle) + .collect() + } else { + vec![vp_handle] + }; + + for &target_handle in &target_handles { + if let Some(acadrust::EntityType::Viewport(vp)) = + self.tabs[i].scene.document.get_entity_mut(target_handle) + { + for h in &freeze_handles { + if !vp.frozen_layers.contains(h) { + vp.frozen_layers.push(*h); + frozen_count += 1; + } + } + for h in &thaw_handles { + let before = vp.frozen_layers.len(); + vp.frozen_layers.retain(|fh| fh != h); + if vp.frozen_layers.len() < before { + thawed_count += 1; + } + } + } + } + + if frozen_count > 0 || thawed_count > 0 { + self.push_undo_snapshot(i, "VPLAYER"); + self.tabs[i].dirty = true; + if frozen_count > 0 { + self.command_line.push_info( + crate::tf!("VPLAYER: {frozen_count} layer(s) frozen in viewport.").as_ref(), + ); + } + if thawed_count > 0 { + self.command_line.push_info( + crate::tf!("VPLAYER: {thawed_count} layer(s) thawed in viewport.").as_ref(), + ); + } + // Sync layer panel so VP freeze columns update immediately. + let doc_layers = self.tabs[i].scene.document.layers.clone(); + let vp_info = self.tabs[i].scene.viewport_list(); + self.tabs[i] + .layers + .sync_with_viewports(&doc_layers, vp_info); + } + + // Show updated prompt (command stays active for more operations). + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + } + + pub(super) fn handle_zoom_to_window(&mut self, p1: glam::DVec3, p2: glam::DVec3) { + let i = self.active_tab; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].scene.remember_current_view(); + self.tabs[i] + .scene + .zoom_to_window(p1.as_vec3(), p2.as_vec3()); + self.command_line + .push_output(crate::t!("Zoom Window").as_ref()); + } + + pub(super) fn handle_set_plot_window(&mut self, p1: glam::DVec3, p2: glam::DVec3) { + let i = self.active_tab; + let layout_name = self.tabs[i].scene.current_layout.clone(); + if layout_name == "Model" { + // Model space: remember the window (world X/Y) for the plot dialog. + let x0 = p1.x.min(p2.x); + let y0 = p1.y.min(p2.y); + let x1 = p1.x.max(p2.x); + let y1 = p1.y.max(p2.y); + self.plot_window = Some((x0, y0, x1, y1)); + self.plot_dialog.window = self.plot_window; + self.command_line.push_output( + crate::tf!("Plot window: {x0:.2},{y0:.2} to {x1:.2},{y1:.2}").as_ref(), + ); + // Pick window closed the plot dialog so the viewport could + // receive the two clicks — bring the dialog back with the + // window now active. + self.plot_dialog.area = "Window".to_string(); + // Remember the pick immediately, like the printer choice. + self.save_config(); + self.active_modal = Some(super::super::ModalKind::Plot); + } else { + // PLOTWINDOW always describes the plotted layout. In MSPACE + // the command points are model coordinates, so map them back + // through the active floating viewport first. + let p1 = self.tabs[i].scene.model_to_paper(p1); + let p2 = self.tabs[i].scene.model_to_paper(p2); + let x1 = p1.x.min(p2.x); + let y1 = p1.y.min(p2.y); + let x2 = p1.x.max(p2.x); + let y2 = p1.y.max(p2.y); + self.plot_window = Some((x1, y1, x2, y2)); + self.plot_dialog.window = self.plot_window; + self.command_line.push_output( + crate::tf!("Plot window: {x1:.2},{y1:.2} to {x2:.2},{y2:.2}").as_ref(), + ); + self.plot_dialog.area = "Window".to_string(); + self.save_config(); + self.active_modal = Some(super::super::ModalKind::Plot); + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + } + + pub(super) fn handle_quick_print(&mut self, handles: Vec) -> Task { + let i = self.active_tab; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + self.on_quick_print_handles(handles) + } +}