use super::{Message, OpenCADStudio}; use crate::command::{CmdResult, SelectionEntity, StepInput}; use acadrust::Handle; use iced::Task; 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 (PR1). self.clear_accepted_snaps(); self.pending_click_snap = None; self.snapper.from_point = None; self.snapper.clear_tracking(); self.otrack_active = 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(); } /// Roll a hot grip back to its pre-drag image and remove every grip-owned /// overlay. Shared by Escape and drawing-space transitions. 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; 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(&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 { // 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; } } 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(); } // Typed / dynamic-input / headless points carry no snap, but the // accepted-snap list must stay index-parallel with the points the // command collects (PR1). Interactive picks record themselves in // the click handler and never reach here. self.record_accepted_snap(i, None, None, *point); } 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 { 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 result: Option = { let Some(cmd) = self.tabs[i].active_cmd.as_mut() else { return Task::none(); }; 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()), } }; match result { Some(r) => self.apply_cmd_result(r), None => Task::none(), } } /// 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 { let i = self.active_tab; 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 crate::plugin::try_dispatch(self, i, cmd) { let toks: Vec = tokens.iter().map(|s| s.to_string()).collect(); return self.finish_active_command(&toks); } if tokens[0].eq_ignore_ascii_case("BACKGROUND") || tokens[0].eq_ignore_ascii_case("COLORSCHEME") { 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); 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. pub(super) fn finish_active_command(&mut self, tokens: &[String]) -> Task { let i = self.active_tab; if self.tabs[i].active_cmd.is_none() { return Task::none(); } self.last_point = None; let mut tasks = Vec::new(); for tok in &tokens[1..] { if self.tabs[i].active_cmd.is_none() { break; } tasks.push(self.feed_active_cmd(tok)); } 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); 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(); } 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 { return self.feed_command(StepInput::Text(token.to_string())); } } /// 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; 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(); } 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 } /// Removes and re-solves the first conflicting constraint in the active scope. pub(super) fn resolve_one_sketch_conflict(&mut self) { let i = self.active_tab; let scope = self.tabs[i].current_sketch_scope(); let Some(set) = self.tabs[i].scene.sketch_constraint_set(scope) else { return }; let Some(&(id, _kind)) = set.conflicts.first() else { return }; let Some(constraint) = set.get(id) else { return }; let touched: Vec = constraint.refs.iter().map(|r| r.entity).collect(); let label = "Remove conflicting constraint"; let constraints_before = set.clone(); let pending = self.begin_undo(i, label, touched.len(), true); self.tabs[i] .scene .record_undo_sketch_constraints_before(scope, constraints_before); self.tabs[i].scene.sketch_constraint_set_mut(scope).remove(id); 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); } } /// 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 .sketch_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(); 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(&changes); } 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 touched: Vec = self.tabs[i] .scene .sketch_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) }) .flat_map(|c| c.refs.iter().map(|r| r.entity)) .collect(); self.tabs[i].dirty = true; if touched.is_empty() { 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(&changes); } /// 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 => { self.tabs[i].scene.named_parameters_mut().remove(¤t.name); let outcome = self.tabs[i].scene.named_parameters_mut().set(&typed, ¤t.source); if outcome.is_err() { let _ = self.tabs[i].scene.named_parameters_mut().set(¤t.name, ¤t.source); } (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 .sketch_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.sketch_constraints[set_index].scope; let before = self.tabs[i].scene.sketch_constraints[set_index].clone(); self.tabs[i].scene.record_undo_sketch_constraints_before(scope, before); for constraint in &mut self.tabs[i].scene.sketch_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(); } } } } } self.resolve_named_parameter_edit(i, &resolve_name); if let Some(pd) = pending { self.commit_undo_delta(i, pd); } self.refresh_properties(); Task::none() } /// 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].dirty = true; if let Some(pd) = pending { self.commit_undo_delta(i, pd); } self.refresh_properties(); Task::none() } 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_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()); match result { 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); } } 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(); } for entity in entities { if preserve_commit_style { let _ = self.commit_entity_handle_preserve_style(entity); } else if preserve_commit_layer { let _ = self.commit_entity_handle_preserve_layer(entity); } else { self.commit_entity(entity); } } 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(); } for entity in entities { if preserve_commit_style { let _ = self.commit_entity_handle_preserve_style(entity); } else if preserve_commit_layer { let _ = self.commit_entity_handle_preserve_layer(entity); } else { 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; 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(); } let label = self.history_label_from_active_cmd(i, "MOVE"); // A move/rotate/scale/mirror mutates only the selected entities // (and their baked dimension sub-entities) through // transform_entities — always delta-safe. let pending = self.begin_undo(i, label, handles.len(), true); self.tabs[i].scene.transform_entities(&handles, &transform); 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) => { // For XATTACH: ensure the xref block definition exists before // committing the INSERT entity that references it. // Extract path early to avoid borrow conflicts. let xattach_path: Option = { let tab = &self.tabs[i]; if let Some(cmd) = tab.active_cmd.as_ref() { if cmd.name() == "XATTACH" { cmd.xattach_path() } else { None } } else { None } }; if let Some(path) = xattach_path { crate::modules::insert::xattach::prepare_xref_block( &mut self.tabs[i].scene, &path, ); // Resolving the xref merged its layer / linetype tables // into the document — mirror them into the Layers panel // and ribbon dropdowns now, not on the next reopen (#407). self.refresh_layer_panel(); } 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); } } 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. self.apply_viewport_dimension_measurement(i, &mut entity); // ...and is deliberately *not* associated in paper space: its // definition points are projected paper coordinates, so a // paper-space association would bind it to geometry that is // not there. PR3 records the real dimension -> viewport -> // entity chain instead. One gate, shared with // `infer_dimension_sources_guarded`. 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 { // Measured through a layout viewport: a paper-space // association would bind projected coordinates to // geometry that is not on the sheet. Record the real // chain instead — dimension -> viewport -> block // path -> entity -> feature — from the AcceptedSnaps // PR1 captured, in definition-point slot order. 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::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::AddSketchConstraint { kind, refs, driving_param, label } => { if let Err(message) = self.tabs[i] .scene .validate_sketch_constraint(kind, &refs, driving_param.as_ref()) { 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_sketch_scope(); let constraints_before = self .tabs[i] .scene .sketch_constraint_set(scope) .cloned() .unwrap_or_else(|| { crate::scene::sketch_constraints::SketchConstraintSet::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_sketch_constraints_before(scope, constraints_before); self.tabs[i].scene.sketch_constraint_set_mut(scope).add(kind, refs, driving_param); 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); 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 { point_a, point_b, label } => { let scope = self.tabs[i].current_sketch_scope(); let to_world = |p: glam::DVec3| acadrust::types::Vector3::new(p.x, p.y, p.z); let resolved_a = crate::scene::sketch_constraints::nearest_sketch_point(&self.tabs[i].scene.document, scope, to_world(point_a), None); let resolved_b = resolved_a.and_then(|ref_a| { crate::scene::sketch_constraints::nearest_sketch_point(&self.tabs[i].scene.document, scope, to_world(point_b), None) .filter(|ref_b| *ref_b != ref_a) }); match (resolved_a, resolved_b) { (Some(ref_a), Some(ref_b)) => { return self.apply_cmd_result(CmdResult::AddSketchConstraint { kind: crate::scene::sketch_constraints::ConstraintKind::Coincident, refs: vec![ref_a, ref_b], driving_param: None, label, }); } _ => { self.command_line.push_error( "Coincident: 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::AddPointOnEntityConstraint { point, target, kind, label } => { let scope = self.tabs[i].current_sketch_scope(); let to_world = |p: glam::DVec3| acadrust::types::Vector3::new(p.x, p.y, p.z); let resolved = crate::scene::sketch_constraints::nearest_sketch_point(&self.tabs[i].scene.document, scope, to_world(point), Some(target)); match resolved { Some(point_ref) => { use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; // `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 { SketchRef::center(target) } else { SketchRef::whole(target) }; return self.apply_cmd_result(CmdResult::AddSketchConstraint { 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_sketch_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::sketch_constraints::nearest_sketch_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::AddSketchConstraint { kind: crate::scene::sketch_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) { let old_handle = old.as_entity().handle(); let layer = old.as_entity().layer().to_string(); let mut new = entity; new.as_entity_mut().set_handle(old_handle); new.as_entity_mut().set_layer(layer); *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(); let _ = 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. self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); self.restore_pre_cmd_tangent(); let _ = 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.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(); 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.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.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(); // 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); 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(&changes); } 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 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. if self.mtext_editor.is_some() { return self.unfocus_widgets(); } // The in-place TEXT editor needs keyboard focus on its own field. if self.text_inline.is_some() { return iced::widget::operation::focus(iced::widget::Id::new( super::view::TEXT_INLINE_ID, )); } self.focus_cmd_input() } /// 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_sketch_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 } 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. 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()); } } #[cfg(test)] mod sketch_constraint_undo_tests { use super::*; use crate::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope}; 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::AddSketchConstraint { kind: ConstraintKind::Horizontal, refs: vec![SketchRef::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.sketch_constraint_set(SketchScope::ModelSpace).unwrap().constraints.len(), 1 ); app.undo_steps(1); assert_eq!( app.tabs[app.active_tab] .scene .sketch_constraint_set(SketchScope::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.sketch_constraint_set(SketchScope::ModelSpace).unwrap().constraints.len(), 1, "redo should restore the constraint record" ); } /// Drawing commands must not create sketch 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 .sketch_constraint_set(SketchScope::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_sketch_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::AddSketchConstraint { kind: ConstraintKind::Horizontal, refs: vec![SketchRef::whole(line)], driving_param: None, label: "Horizontal constraint", }); let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint { kind: ConstraintKind::Horizontal, refs: vec![SketchRef::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.sketch_constraint_set(SketchScope::ModelSpace).unwrap(); assert_eq!(set.constraints.len(), 2); assert_eq!(set.conflicts.len(), 1, "the duplicate should have been flagged"); app.resolve_one_sketch_conflict(); let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::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.sketch_constraint_set(SketchScope::ModelSpace).unwrap(); assert_eq!(set.constraints.len(), 2, "undo should restore the removed constraint"); } /// `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::AddSketchConstraint { kind: ConstraintKind::Distance, refs: vec![SketchRef::point(line, 0), SketchRef::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)); } /// 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::AddSketchConstraint { kind: ConstraintKind::Distance, refs: vec![SketchRef::point(line, 0), SketchRef::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::AddSketchConstraint { kind: ConstraintKind::Parallel, refs: vec![SketchRef::whole(a), SketchRef::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 { point_a: glam::DVec3::new(5.0, 0.0, 0.0), // a's end point_b: glam::DVec3::new(20.0, 0.0, 0.0), // b's start label: "Coincident constraint", }); let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::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"); } /// 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 { point_a: glam::DVec3::new(5.0, 0.0, 0.0), // a's end — a real point point_b: glam::DVec3::new(500.0, 500.0, 0.0), // nowhere near anything label: "Coincident constraint", }); assert_eq!( app.tabs[app.active_tab] .scene .sketch_constraint_set(SketchScope::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()); } }