//! Command-result driver: routes [`CmdResult`]s from interactive commands into host state. //! //! # Router (no wildcard) //! //! `apply_cmd_result_inner` is a thin exhaustive `match` over [`CmdResult`]: //! every variant has an explicit arm and there is intentionally NO wildcard arm, //! so adding a variant fails to compile until it is routed here. //! //! # Handler signature rule (T5) //! //! Router arms stay thin: each forwards to a `handle_*` method on `OpenCADStudio`. //! - Payloads with **3 or fewer fields** are destructured at the router and passed //! as individual arguments. //! - **Wide payloads** (more than 3 fields) are passed whole via //! `result @ Variant { .. }` and destructured inside the handler with `let-else`. //! - **Or-pattern** arms (e.g. `Cancel | CancelForSpaceChange`) pass the enum itself //! so the handler can tell the cases apart with `matches!`. //! - Return type follows the arm's control flow so the router's post-match focus //! bookkeeping runs exactly when it used to: `()` when the arm falls through, //! `Task` when every path diverges with `return`, and //! `Option>` (`Some` = return, `None` = fall through) when the arm //! mixes both. `Relaunch`/`Dispatch` instead produce the `dispatched` task that //! the router assigns. //! //! Handler bodies are verbatim 1:1 moves of the original match arms (plus the //! mechanical `let i = self.active_tab;` prologue). //! //! # Adding a variant (T1 exhaustiveness guide) //! //! There is intentionally NO wildcard arm in the router, so adding a //! [`CmdResult`] variant fails to compile until it is routed here. To add one: //! 1. add an explicit arm in `apply_cmd_result_inner` following the T5 rule; //! 2. put the `handle_*` body in the owning file below (extend the `impl` //! block there — never inline a body in the router); //! 3. if no file owns the new concern, extend this list and document the new //! file here. //! //! # Router-only exception (T6) //! //! Only the router (`apply_cmd_result` / `apply_cmd_result_inner`), flow and //! lifecycle arms, group/table arms, mleader arms, and helpers with callers in //! more than one domain file live in `mod.rs`: `merge_dependencies` (also //! called from `app::update::dialog`), `restore_pre_cmd_tangent` (called from //! every domain file), and `entity_at_typed_point` (called from //! `utilities.rs`). Everything else lives in its domain file. //! //! # File ownership map //! //! - `utilities.rs` — generic pre-match helpers. //! - `parameters.rs` — parametric parameters, named-parameter editor rows, //! dynamic dimensions, and the constraint-inference entry points //! (`apply_continuous_constraints`, `apply_inferred_constraints`) shared by //! the `constraint.rs` arms. //! - `property_match.rs` — MATCHPROP/LAYMATCH arms (special-prop kernels live //! in `crate::entities::match_props`). //! - `entity_commit.rs` — entity/dimension/hatch/block commit arms. //! - `solid3d.rs` — extrude/thicken/presspull/revolve/sweep/loft/shell/boolean/slice. //! - `viewport.rs` — MVIEW/layout/plot/zoom/print arms. //! - `transform.rs` — MOVE/COPY/ALIGN/STRETCH arms. //! - `clipboard.rs` — COPYCLIP/PASTECLIP/PASTEORIG/PASTEBLOCK arms, //! clipboard merge + xdict helpers; re-exports //! `remap_ext_subtree_reference` and `copy_to_clipboard_kernel` //! for `app::commands::blocks`. //! - `modify.rs` — LENGTHEN/DIVIDE/MEASURE/PEDIT/JOIN/BREAK/HATCHEDIT arms + //! `apply_pedit_result`. (Entity BREAK itself delegates to //! `break_cmd::break_entity`; the DIMBREAK free helpers live in `dim_edit`.) //! - `text_edit.rs` — MTEXT/TEXT edit-session arms (incl. DDEDIT). //! - `constraint.rs` — parametric/geometric constraint arms (moved LAST: //! active development area). //! - `dim_edit.rs` — DIMBREAK/DIMJOGLINE/DIMSPACE arms + `apply_dimspace`, //! `dimension_auto_spacing`, and the dimbreak free helpers //! (`DimBreakResult`, `wire_segments`, `segment_intersection_xy`, //! `break_object_handle`, `remove/write_dimension_break_data`, //! `break_reference`, `apply_dimbreak`). //! - mleader arms + `apply_mleader_align`/`collect` helpers live in `mod.rs` //! (router-only exception). use super::{Message, OpenCADStudio}; use crate::command::{CmdResult, SelectionEntity, StepInput}; use codec::Handle; use iced::Task; pub(super) mod clipboard; mod constraint; mod dim_edit; mod entity_commit; mod modify; mod parameters; mod property_match; mod solid3d; mod tests; mod text_edit; mod transform; mod utilities; mod viewport; pub(crate) use clipboard::{ copy_to_clipboard_kernel, paste_entities_kernel, remap_ext_subtree_reference, PasteSpace, }; impl OpenCADStudio { /// Applies one command result, then — when that result ended the active /// command — drops the selection. Editing tools (MOVE, COPY, ROTATE, …) and /// every other interactive command leave nothing selected once they finish, /// so a follow-up edit doesn't silently reuse the previous working set. /// /// `Relaunch`/`Dispatch` are excepted: they end the front-end command only /// to immediately start another and hand it a deliberate selection (the /// pick-first selector relaunching MOVE on the picked set works this way). /// Pure-selection commands (SELECTALL, QSELECT, …) run without an active /// command, so `was_active` is false and their selection is preserved. pub(super) fn apply_cmd_result(&mut self, mut result: CmdResult) -> Task { let i = self.active_tab; if let CmdResult::ReturnPoint(pt) = result { self.tabs[i].scene.clear_preview_wire(); if let Some(mut suspended) = self.tabs[i].suspended_cmd.take() { self.last_point = Some(pt); let next_res = suspended.on_point(pt); self.tabs[i].active_cmd = Some(suspended); result = next_res; } else { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.restore_pre_cmd_tangent(); return Task::none(); } } let settings = self.tabs[self.active_tab] .active_cmd .as_ref() .and_then(|command| command.sketch_settings()); if let Some((sketch_type, increment, tolerance)) = settings { let tab = &mut self.tabs[self.active_tab]; let changed = tab.scene.document.header.sketch_type != sketch_type || (tab.scene.document.header.sketch_increment - increment).abs() > f64::EPSILON || (tab.scene.document.header.sketch_tolerance - tolerance).abs() > f64::EPSILON; if changed { crate::io::set_sketch_settings( &mut tab.scene.document, sketch_type, increment, tolerance, ); tab.dirty = true; } } let mline_settings = self.tabs[self.active_tab] .active_cmd .as_ref() .and_then(|command| command.mline_settings()); if let Some((scale, justification, style_name, style_handle)) = mline_settings { let tab = &mut self.tabs[self.active_tab]; let header = &mut tab.scene.document.header; let style_handle = style_handle.unwrap_or(codec::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::QSelectSample { .. } | CmdResult::SolidEdgeBlend { .. } | CmdResult::SolidSubtract { .. } | CmdResult::SliceEntities { .. } | CmdResult::SliceSurfaceEntities { .. } ); let task = self.apply_cmd_result_inner(result); let i = self.active_tab; // A transparent zoom that just finished hands control back to the // command it interrupted before the "command ended" bookkeeping below. self.resume_transparent_parent(i); let preview_hidden = self.tabs[i] .active_cmd .as_ref() .map(|command| command.preview_hidden_handles().to_vec()) .unwrap_or_default(); self.tabs[i] .scene .set_command_preview_hidden(&preview_hidden); if was_active && !preserve_selection && self.tabs[i].active_cmd.is_none() && !self.tabs[i].scene.selected.is_empty() { // Remember the working set for the "Previous" selection keyword // (#426) before dropping it. self.tabs[i].prev_selection = self.tabs[i].scene.selected.iter().copied().collect(); self.tabs[i].scene.deselect_all(); self.refresh_properties(); } // A command just ended (any terminal result) — if it was the draw // command an ADDSELECTED launched, revert the template-property override // so CLAYER / CECOLOR / … are left unchanged (#239). No-op otherwise. if was_active && self.tabs[i].active_cmd.is_none() { self.tabs[i].scene.set_hover_highlight(None); self.command_line.set_step_options(Vec::new()); self.command_line.clear_contextual_suggestions(); self.restore_add_selected_defaults(); self.tabs[i].pending_pause_tokens = None; } if self.tabs[i].active_cmd.is_some() && self.tabs[i].pending_pause_tokens.is_some() { let drain_task = self.drain_pending_pause_tokens(i); Task::batch([task, drain_task]) } else { task } } /// Resolve the cell under `click` on table `handle` (or any table in the drawing /// if `handle` is `Handle::NULL`), arm the cell indicator, and launch the cell /// editor unless the cell's content is locked. Shared by the double-click shortcut /// and the TABLEDIT point pick so both agree on grid coordinates and editor setup. pub(crate) fn begin_table_cell_edit( &mut self, i: usize, handle: codec::Handle, click: glam::DVec3, ) -> crate::modules::annotate::table_cmd::TableCellEditStart { use crate::modules::annotate::table_cmd::{table_cell_at, TableCellEditStart}; let target = if handle != codec::Handle::NULL { self.tabs[i] .scene .document .get_entity(handle) .and_then(|entity| match entity { codec::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 codec::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 { codec::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 { codec::objects::ObjectType::TableStyle(style) => Some(style), _ => None, }) }); let Some(hit) = table_cell_at(&table, style, click) else { return TableCellEditStart::NoCell; }; // Arm the Properties cell indicator whether or not the cell turns // out to be editable (matches the double-click behavior). let index = hit.index(&table); self.tabs[i].properties.prop_vertex = index; self.tabs[i].properties.prop_vertex_indicator_active = true; crate::entities::table::set_prop_current_cell(index); self.refresh_properties(); if hit.locked { return TableCellEditStart::LockedCell; } let command = crate::modules::annotate::table_cmd::TableCellEditCommand::new( handle, &table, hit.row, hit.column, ); self.command_line .push_info(&crate::command::CadCommand::prompt(&command)); self.tabs[i].active_cmd = Some(Box::new(command)); TableCellEditStart::Started } /// Reports a refused command-line value and shows the prompt again. fn reprompt_active_command(&mut self, i: usize, message: &str) { self.command_line.push_error(message); if let Some(prompt) = self.tabs[i] .active_cmd .as_ref() .map(|command| command.prompt()) { self.command_line.push_info(&prompt); } } /// Bring back the command a transparent zoom parked, once the zoom is /// over (no command active any more). No-op otherwise. pub(in crate::app) fn resume_transparent_parent(&mut self, i: usize) { if !self.tabs[i].transparent_resume { return; } if self.tabs[i].active_cmd.is_some() { // The transparent prompt is still up (or the parent was already // restored by a Cancel). if self.tabs[i].suspended_cmd.is_none() { self.tabs[i].transparent_resume = false; } return; } self.tabs[i].transparent_resume = false; let Some(parent) = self.tabs[i].suspended_cmd.take() else { return; }; self.tabs[i].active_cmd = Some(parent); let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { self.command_line.push_info(&p); } let opts = self.tabs[i] .active_cmd .as_ref() .map(|c| c.options()) .unwrap_or_default(); self.command_line.set_step_options(opts); self.refresh_active_cmd_preview(i); } pub(in crate::app) fn start_mtp_modifier(&mut self, i: usize) { let parent = self.tabs[i].active_cmd.take(); self.tabs[i].suspended_cmd = parent; self.tabs[i].scene.clear_preview_wire(); let cmd = crate::command::Mid2PointCommand::new(); let prompt = crate::command::CadCommand::prompt(&cmd); self.tabs[i].active_cmd = Some(Box::new(cmd)); self.command_line.push_info(&prompt); self.command_line.set_step_options(Vec::new()); self.refresh_active_cmd_preview(i); } /// Apply a command replacement without deleting a one-to-one entity edit. /// Splits and entity-type conversions still allocate replacement identities. fn replace_command_entity( &mut self, tab: usize, handle: Handle, mut entities: Vec, ) -> Vec { let same_type = entities.len() == 1 && self.tabs[tab] .scene .document .get_entity(handle) .is_some_and(|original| { std::mem::discriminant(original) == std::mem::discriminant(&entities[0]) }); let handles = if same_type { let mut entity = entities.pop().expect("one replacement"); entity.common_mut().handle = handle; // Scene::update_entity also retains the live owning block and // refreshes dependent render caches without erase notifications. if self.tabs[tab].scene.update_entity(entity) { vec![handle] } else { Vec::new() } } else { let owner = self.tabs[tab] .scene .document .get_entity(handle) .map(|entity| entity.common().owner_handle); let pieces: Vec = 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(); // Hand the hatch associations over before the original goes. self.tabs[tab].scene.split_hatch_source(handle, &pieces); self.tabs[tab].scene.erase_entities(&[handle]); pieces }; for &updated in &handles { if matches!( self.tabs[tab].scene.document.get_entity(updated), Some(codec::EntityType::Dimension(_)) ) { self.tabs[tab].scene.invalidate_dim_block_recorded(updated); } } handles } fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task { let i = self.active_tab; if let Some(bind) = self.tabs[i] .active_cmd .as_ref() .and_then(|command| command.nested_copy_bind_setting()) { if self.ncopy_bind != bind { self.ncopy_bind = bind; self.persist_settings_if_changed(); } } let preserve_commit_style = self.tabs[i] .active_cmd .as_ref() .is_some_and(|command| command.preserve_commit_style()); let preserve_commit_layer = self.tabs[i] .active_cmd .as_ref() .is_some_and(|command| command.preserve_commit_layer()); // Task produced by a command the arm dispatches; it must reach the // runtime or messages such as a chosen render mode are dropped. let mut dispatched = Task::none(); match result { CmdResult::QSelectSample { state, tab_id, handle, } => { self.finish_qselect_sample(*state, tab_id, handle); } CmdResult::OpenAutoConstrainSettings => { self.handle_open_auto_constrain_settings(); } CmdResult::NeedPoint => { if let Some(task) = self.handle_need_point() { return task; } } CmdResult::Preview(wire) => { self.handle_preview(wire); } CmdResult::InterimWire(wire) => { self.handle_interim_wire(wire); } CmdResult::CommitEntity(entity) => { if let Some(task) = self.handle_commit_entity(entity, preserve_commit_layer) { return task; } } CmdResult::CommitEntities(entities) => { if let Some(task) = self.handle_commit_entities( entities, preserve_commit_style, preserve_commit_layer, ) { return task; } } CmdResult::CommitEntitiesAndExit(entities) => { self.handle_commit_entities_and_exit( entities, preserve_commit_style, preserve_commit_layer, ); } CmdResult::MviewCreate { viewport, preserve_view, } => { self.handle_mview_create(viewport, preserve_view); } CmdResult::MviewCreateClipped { boundary, boundary_handle, target, } => { if !target.is_null() { self.handle_vpclip(target, boundary, boundary_handle); } else if let Some(task) = self.handle_mview_create_clipped(boundary, boundary_handle) { return task; } } CmdResult::WipeoutFromPolyline { handle, erase_source, } => { if let Some(task) = self.handle_wipeout_from_polyline(handle, erase_source) { return task; } } CmdResult::MviewSwitchLayout(layout) => return self.handle_mview_switch_layout(layout), CmdResult::MviewCancelToLayout(layout) => { return self.handle_mview_cancel_to_layout(layout) } CmdResult::TransformSelected(handles, transform) => { if let Some(task) = self.handle_transform_selected(handles, transform) { return task; } } CmdResult::CopySelected(handles, transform) => { if let Some(task) = self.handle_copy_selected(handles, transform) { return task; } } CmdResult::CopyToClipboard { handles, base } => { self.handle_copy_to_clipboard(handles, base); } CmdResult::CommitAndExit(entity) => { if let Some(task) = self.handle_commit_and_exit(entity, preserve_commit_layer) { return task; } } result @ CmdResult::CommitDimension { .. } => { if let Some(task) = self.handle_commit_dimension(result) { return task; } } CmdResult::CommitDimensionsAndExit(dimensions) => { self.handle_commit_dimensions_and_exit(dimensions); } CmdResult::SetQuickDimensionSnapPriority(priority) => { self.handle_set_quick_dimension_snap_priority(priority); } CmdResult::AlignMLeaders { handles, from, to } => { self.handle_align_mleaders(handles, from, to); } CmdResult::CollectMLeaders { handles, point } => { self.handle_collect_mleaders(handles, point); } result @ CmdResult::CommitSolid { .. } => { self.handle_commit_solid(result); } CmdResult::CommitAndEditText(entity) => { if let Some(task) = self.handle_commit_and_edit_text(entity) { return task; } } CmdResult::CommitManyAndEditText { entities, edit_index, open_editor, } => { if let Some(task) = self.handle_commit_many_and_edit_text(entities, edit_index, open_editor) { return task; } } CmdResult::CreateBlock { handles, name, base, } => { if let Some(task) = self.handle_create_block(handles, name, base) { return task; } } CmdResult::CreateBlockWithOptions { options } => { if let Some(task) = self.handle_create_block_with_options(options) { return task; } } CmdResult::CommitHatch(hatch) => { self.handle_commit_hatch(hatch); } CmdResult::CommitStyledHatch { hatch, color, transparency, } => { self.handle_commit_styled_hatch(hatch, color, transparency); } CmdResult::CommitHatchWithBoundaries { hatch, boundaries, entity_style, } => { self.handle_commit_hatch_with_boundaries(hatch, boundaries, entity_style); } CmdResult::CommitHatches { hatches, entity_style, } => { self.handle_commit_hatches(hatches, entity_style); } CmdResult::BatchCopy(handles, transforms) => { if let Some(task) = self.handle_batch_copy(handles, transforms) { return task; } } CmdResult::ReplaceMany(replacements, additions) => { if let Some(task) = self.handle_replace_many(replacements, additions) { return task; } } CmdResult::ReplaceManyContinue(replacements) => { if let Some(task) = self.handle_replace_many_continue(replacements) { return task; } } CmdResult::XClip { inserts, action } => { self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.apply_xclip(i, inserts, action); } CmdResult::OpenPdfImportSettings => self.open_pdf_import_settings(), CmdResult::PdfImportFile(import) => { self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.run_pdf_import( i, crate::app::commands::pdf_import::PdfImportSource::File(import), ); } CmdResult::AttachPointCloud { placement, message } => { let label = self.history_label_from_active_cmd(i, "POINTCLOUDATTACH"); self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.command_line.push_output(&message); self.attach_point_cloud(i, label, placement); self.restore_pre_cmd_tangent(); } CmdResult::AttachPdfPages { kind, path, pages } => { let label = self.history_label_from_active_cmd(i, "PDFATTACH"); self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.attach_pdf_pages(i, label, kind, &path, pages); self.restore_pre_cmd_tangent(); } CmdResult::PdfImport(request) => { self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.run_pdf_import( i, crate::app::commands::pdf_import::PdfImportSource::Underlay(request), ); } CmdResult::UpdateEntityAndFinish { handle, entity } => { if let Some(task) = self.handle_update_entity_and_finish(handle, entity) { return task; } } result @ CmdResult::AddParametricConstraint { .. } => { if let Some(task) = self.handle_add_parametric_constraint(result) { return task; } } result @ CmdResult::AddEqualConstraint { .. } => { if let Some(task) = self.handle_add_equal_constraint(result) { return task; } } CmdResult::CheckConstraintPoint(pick) => { return self.handle_check_constraint_point(pick) } result @ CmdResult::AddDimensionalConstraint { .. } => { if let Some(task) = self.handle_add_dimensional_constraint(result) { return task; } } CmdResult::CancelWithMessage(message) => { return self.handle_cancel_with_message(message) } result @ CmdResult::AddRadialConstraint { .. } => { if let Some(task) = self.handle_add_radial_constraint(result) { return task; } } result @ CmdResult::AddAngularConstraint { .. } => { if let Some(task) = self.handle_add_angular_constraint(result) { return task; } } result @ CmdResult::MakeParallel { .. } => return self.handle_make_parallel(result), CmdResult::AddFixedConstraint(pick) => return self.handle_add_fixed_constraint(pick), CmdResult::CheckHorizontalPoint { kind, pick } => { return self.handle_check_horizontal_point(kind, pick) } result @ CmdResult::AddHorizontalConstraint { .. } => { if let Some(task) = self.handle_add_horizontal_constraint(result) { return task; } } CmdResult::AddSymmetricConstraint { selection, axis, label, } => { if let Some(task) = self.handle_add_symmetric_constraint(selection, axis, label) { return task; } } result @ CmdResult::AddPerpendicularConstraint { .. } => { if let Some(task) = self.handle_add_perpendicular_constraint(result) { return task; } } CmdResult::AddTangentConstraint { first, second, label, } => { if let Some(task) = self.handle_add_tangent_constraint(first, second, label) { return task; } } CmdResult::AddConcentricConstraint { first, second, label, } => { if let Some(task) = self.handle_add_concentric_constraint(first, second, label) { return task; } } result @ CmdResult::AddCoincidentConstraint { .. } => { if let Some(task) = self.handle_add_coincident_constraint(result) { return task; } } CmdResult::AddAutoCoincidentConstraints { handles } => { self.handle_add_auto_coincident_constraints(handles); } result @ CmdResult::AddPointOnEntityConstraint { .. } => { if let Some(task) = self.handle_add_point_on_entity_constraint(result) { return task; } } CmdResult::AddEqualDistanceConstraint { points, label } => { if let Some(task) = self.handle_add_equal_distance_constraint(points, label) { return task; } } CmdResult::ReassociateCenterMark { target, source, point, } => { if let Some(task) = self.handle_reassociate_center_mark(target, source, point) { return task; } } CmdResult::EditDimensionBreak { dimensions, operation, } => { self.handle_edit_dimension_break(dimensions, operation); } CmdResult::EditDimensionJog { dimension, point } => { self.handle_edit_dimension_jog(dimension, point); } CmdResult::SpaceDimensions { base, others, spacing, } => { self.handle_space_dimensions(base, others, spacing); } CmdResult::ReplaceEntity(handle, new_entities) => { if let Some(task) = self.handle_replace_entity(handle, new_entities) { return task; } } CmdResult::CommitExplodedInsert { insert, keep_going } => { let i = self.active_tab; if let codec::EntityType::Insert(ins) = &insert { let name = ins.block_name.clone(); self.record_block_insert(&name); } let pieces = crate::modules::draw::modify::explode::explode_entity( &insert, &self.tabs[i].scene.document, ); if keep_going { if let Some(task) = self.handle_commit_entities( pieces, preserve_commit_style, preserve_commit_layer, ) { return task; } } else { self.handle_commit_entities_and_exit( pieces, preserve_commit_style, preserve_commit_layer, ); } } CmdResult::AttreqNeeded { block_name } => { if let Some(task) = self.handle_attreq_needed(block_name) { return task; } } CmdResult::CommitLiveEntity(entity) => { self.handle_commit_live_entity(entity); } CmdResult::UpdateLiveEntity { handle, entity, finish, } => { self.handle_update_live_entity(handle, entity, finish); } CmdResult::FinalizeLiveEntity(handle) => { self.handle_finalize_live_entity(handle); } CmdResult::RemoveLiveEntity(handle) => { self.handle_remove_live_entity(handle); } cancel @ (CmdResult::Cancel | CmdResult::CancelForSpaceChange) => { self.handle_cancel(cancel); } CmdResult::SelectByPath { path, closed, crossing, } => return self.handle_select_by_path(path, closed, crossing), CmdResult::Relaunch(cmd, handles) => { dispatched = self.handle_relaunch(cmd, handles); } CmdResult::Dispatch(cmd) => { dispatched = self.handle_dispatch(cmd); } CmdResult::EditTableCell { handle, point } => { if let Some(task) = self.handle_edit_table_cell(handle, point) { return task; } } CmdResult::MatchEntityLayer { dest, src } => { self.handle_match_entity_layer(dest, src); } CmdResult::MatchProperties { dest, src } => { if let Some(task) = self.handle_match_properties(dest, src) { return task; } } CmdResult::PasteClipboard { base_pt } => { self.handle_paste_clipboard(base_pt); } CmdResult::CreateGroup { handles, name } => { if let Some(task) = self.handle_create_group(handles, name) { return task; } } CmdResult::DeleteGroups { handles } => { if let Some(task) = self.handle_delete_groups(handles) { return task; } } CmdResult::VpLayerUpdate { vp_handle, freeze, thaw, } => { self.handle_vp_layer_update(vp_handle, freeze, thaw); } CmdResult::ZoomToWindow { p1, p2 } => { self.handle_zoom_to_window(p1, p2); } CmdResult::TwistView(twist) => { self.handle_twist_view(twist); } CmdResult::Measurement(msg) => { self.handle_measurement(msg); } CmdResult::ReportMeasurement(msg) => { self.handle_report_measurement(msg); } CmdResult::ReportError(msg) => { self.handle_report_error(msg); } CmdResult::ReportMeasurementAndDeselect(msg) => { self.handle_report_measurement_and_deselect(msg); } CmdResult::DeselectAndContinue => { self.handle_deselect_and_continue(); } result @ CmdResult::AlignSelected { .. } => { self.handle_align_selected(result); } CmdResult::LengthenEntity { handle, pick_pt, mode, } => { if let Some(task) = self.handle_lengthen_entity(handle, pick_pt, mode) { return task; } } CmdResult::DivideEntity { handle, n, marker } => { self.handle_divide_entity(handle, n, marker); } result @ CmdResult::MeasureEntity { .. } => { self.handle_measure_entity(result); } CmdResult::PeditOp { handle, op } => return self.handle_pedit_op(handle, op), CmdResult::JoinToSource { source, handles } => { if let Some(task) = self.handle_join_to_source(source, handles) { return task; } } CmdResult::JoinEntities(handles) => { if let Some(task) = self.handle_join_entities(handles) { return task; } } CmdResult::BreakEntity { handle, p1, p2 } => { if let Some(task) = self.handle_break_entity(handle, p1, p2) { return task; } } CmdResult::SetPlotWindow { p1, p2 } => { self.handle_set_plot_window(p1, p2); } CmdResult::QuickPrint(handles) => return self.handle_quick_print(handles), CmdResult::StretchWindow { handles, windows } => { self.handle_stretch_window(handles, windows); } CmdResult::StretchEntities { handles, windows, delta, } => { if let Some(task) = self.handle_stretch_entities(handles, windows, delta) { return task; } } // ── EXTRUDE ──────────────────────────────────────────────────── result @ CmdResult::ExtrudeEntities { .. } => { if let Some(task) = self.handle_extrude_entities(result) { return task; } } CmdResult::ThickenEntities { handles, distance } => { if let Some(task) = self.handle_thicken_entities(handles, distance) { return task; } } result @ CmdResult::PresspullPick { .. } => { self.handle_presspull_pick(result); } CmdResult::PresspullApply { targets, distance, color: _, } => { self.handle_presspull_apply(targets, distance); } // ── REVOLVE ──────────────────────────────────────────────────── result @ CmdResult::RevolveEntities { .. } => { if let Some(task) = self.handle_revolve_entities(result) { return task; } } // ── SWEEP ───────────────────────────────────────────────────── result @ CmdResult::SweepEntities { .. } => { self.handle_sweep_entities(result); } // ── LOFT ────────────────────────────────────────────────────── result @ CmdResult::LoftEntities { .. } => { if let Some(task) = self.handle_loft_entities(result) { return task; } } result @ CmdResult::SolidEdgeBlend { .. } => { return self.handle_solid_edge_blend(result) } CmdResult::SolidShell { handle, actions, distance, } => return self.handle_solid_shell(handle, actions, distance), CmdResult::SolidSubtract { bases, cutters, convert_meshes, } => return self.handle_solid_subtract(bases, cutters, convert_meshes), CmdResult::SliceEntities { targets, plane, keep_point, } => return self.handle_slice_entities(targets, plane, keep_point), CmdResult::SliceSurfaceEntities { targets, cutter, keep_point, } => return self.handle_slice_surface_entities(targets, cutter, keep_point), result @ CmdResult::HatcheditApply { .. } => { if let Some(task) = self.handle_hatchedit_apply(result) { return task; } } result @ CmdResult::OpenMTextEditor { .. } => { self.handle_open_mtext_editor(result); } CmdResult::SuspendForMTextInput { pos, initial, height, } => { self.handle_suspend_for_mtext_input(pos, initial, height); } result @ CmdResult::OpenTextEditor { .. } => { self.handle_open_text_editor(result); } CmdResult::SuspendForTextInput { pos, entity } => { self.handle_suspend_for_text_input(pos, entity); } CmdResult::EditTextEntity { handle } => return self.handle_edit_text_entity(handle), CmdResult::SuspendForTextEdit { handle } => { return self.handle_suspend_for_text_edit(handle) } CmdResult::UndoDocument => { self.handle_undo_document(); } CmdResult::SetTexteditMode(val) => { self.handle_set_textedit_mode(val); } CmdResult::EditDimensions { handles, operation } => { self.handle_edit_dimensions(handles, operation); } CmdResult::DdeditEntity { handle, new_text } => { if let Some(task) = self.handle_ddedit_entity(handle, new_text) { return task; } } CmdResult::ReturnPoint(_) => { self.handle_return_point(); } } // When no command is running the ribbon tool button still has to // visually deactivate. Keyboard focus is assigned below to whichever // editor currently owns typed input. if self.tabs[i].active_cmd.is_none() { self.ribbon.deactivate_tool(); } // The rich text canvas owns keyboard editing itself. Leaving the // hidden command input focused would make it consume Left/Right before // the editor can handle them. let focus = if self.mtext_editor.is_some() { self.unfocus_widgets() } else if self.text_inline.is_some() { // The in-place TEXT editor needs keyboard focus on its own field. iced::widget::operation::focus(iced::widget::Id::new(super::view::TEXT_INLINE_ID)) } else { self.focus_cmd_input() }; Task::batch([dispatched, focus]) } // --- C2a handlers: flow / lifecycle (thin-router targets, bodies moved verbatim) --- fn handle_need_point(&mut self) -> Option> { let i = self.active_tab; // ATTEDIT finished its entity pick: hand the chosen block off to // the attribute editor dialog and end the command (open_attribute // _editor reports "no attributes" / "select a block" as needed). let attedit_handle = self.tabs[i] .active_cmd .as_ref() .and_then(|c| c.attedit_pending_handle()); if let Some(ins_handle) = attedit_handle { self.tabs[i].active_cmd = None; self.command_line.set_step_options(Vec::new()); self.open_attribute_editor(ins_handle); return Some(Task::none()); } let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { self.command_line.push_info(&p); } let opts = self.tabs[i] .active_cmd .as_ref() .map(|c| c.options()) .unwrap_or_default(); self.command_line.set_step_options(opts); if !self.tabs[i] .active_cmd .as_ref() .is_some_and(|c| c.entity_pick_highlights_hover()) { self.tabs[i].scene.set_hover_highlight(None); } // The command may have advanced to a step with a different // dynamic-input shape (e.g. FILLET object-pick → radius entry). // Rebuild the fields now so the matching box appears immediately // and typed digits land in it rather than the command line, // instead of waiting for the next cursor move to resync. self.sync_dyn_fields(); self.refresh_area_preview(i); None } fn handle_preview(&mut self, wire: crate::scene::model::wire_model::WireModel) { let i = self.active_tab; self.tabs[i].scene.set_preview_wires(vec![wire]); let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { self.command_line.push_info(&p); } } fn handle_interim_wire(&mut self, wire: crate::scene::model::wire_model::WireModel) { let i = self.active_tab; self.tabs[i].scene.set_interim_wire(wire); let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { self.command_line.push_info(&p); } } fn handle_cancel(&mut self, cancel: CmdResult) { let i = self.active_tab; let space_changed = matches!(cancel, CmdResult::CancelForSpaceChange); self.tabs[i].scene.clear_preview_wire(); if !space_changed && self.tabs[i].suspended_cmd.is_some() { self.tabs[i].active_cmd = self.tabs[i].suspended_cmd.take(); let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { self.command_line.push_info(&p); } let opts = self.tabs[i] .active_cmd .as_ref() .map(|c| c.options()) .unwrap_or_default(); self.command_line.set_step_options(opts); self.refresh_active_cmd_preview(i); } else { self.tabs[i].suspended_cmd = None; self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.restore_pre_cmd_tangent(); self.command_line.push_info( crate::t!(if space_changed { "Command cancelled because the active drawing space changed." } else { "Command cancelled." }) .as_ref(), ); } } fn handle_cancel_with_message(&mut self, message: String) -> Task { for line in message.lines() { self.command_line.push_error(line); } self.apply_cmd_result(CmdResult::Cancel) } fn handle_select_by_path( &mut self, path: Vec<[f64; 2]>, closed: bool, crossing: bool, ) -> Task { let i = self.active_tab; // The command picked the path; the hit test lives here, where // the camera and the drawing's geometry are. Everything after // matches what a lasso does, Remove included. let canvas = self.tabs[i].scene.selection.borrow().view.vp_size; let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: canvas.0, height: canvas.1, }; let edit_cam = self.tabs[i] .scene .viewport_edit_frame(canvas) .map(|(cam, _)| cam); let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds); let screen: Vec = path .iter() .map(|p| { crate::scene::pick::hit_test::world_to_screen( glam::DVec3::new(p[0], p[1], 0.0), view_rot, eye, bounds, ) }) .collect(); let handles = self.tabs[i].scene.path_hit_handles( &screen, crossing, !closed, all_wires, view_rot, eye, bounds, |point| self.cursor_model_point(i, &edit_cam, point, bounds), ); if self.select_remove_mode { for h in &handles { self.tabs[i].scene.deselect_entity(*h); } } else { for h in &handles { self.tabs[i].scene.select_entity(*h, false); } self.tabs[i].scene.expand_selection_for_groups(&handles); } self.refresh_properties(); let selected: Vec = self.tabs[i] .scene .selected_entities() .into_iter() .map(|(h, _)| h) .collect(); let count = handles.len(); self.command_line .push_info(crate::tf!("{count} object(s) found.").as_ref()); self.feed_command(StepInput::SelectionComplete(selected)) } fn handle_relaunch(&mut self, cmd: String, handles: Vec) -> Task { let i = self.active_tab; self.tabs[i].scene.deselect_all(); for h in &handles { self.tabs[i].scene.select_entity(*h, false); } self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); self.restore_pre_cmd_tangent(); self.dispatch_command(&cmd) } fn handle_dispatch(&mut self, cmd: String) -> Task { let i = self.active_tab; // End this interactive front-end, then run the assembled command // through the normal dispatcher. Selection is left untouched. // The option picked in DIMCONSTRAINT becomes its next default; // the same command run on its own leaves the default alone. let from_menu = self.tabs[i] .active_cmd .as_ref() .is_some_and(|command| command.name() == "DIMCONSTRAINT"); if from_menu { match cmd.as_str() { "DCRADIUS" => self.dim_constraint_last = "Radius", "DCDIAMETER" => self.dim_constraint_last = "Diameter", "DCCONVERT" => self.dim_constraint_last = "Convert", _ => {} } } self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); self.restore_pre_cmd_tangent(); self.dispatch_command(&cmd) } fn handle_measurement(&mut self, msg: String) { let i = self.active_tab; self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); self.restore_pre_cmd_tangent(); self.command_line.push_output(&msg); } fn handle_report_measurement(&mut self, msg: String) { let i = self.active_tab; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); self.refresh_area_preview(i); self.command_line.push_output(&msg); if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()) { self.command_line.push_info(&prompt); } } fn handle_report_error(&mut self, msg: String) { let i = self.active_tab; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); self.command_line.push_error(&msg); if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()) { self.command_line.push_info(&prompt); } } fn handle_report_measurement_and_deselect(&mut self, msg: String) { let i = self.active_tab; self.tabs[i].snap_result = None; self.tabs[i].scene.deselect_all(); self.tabs[i].scene.clear_preview_wire(); self.refresh_area_preview(i); self.refresh_properties(); self.command_line.push_output(&msg); if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()) { self.command_line.push_info(&prompt); } } fn handle_deselect_and_continue(&mut self) { let i = self.active_tab; self.tabs[i].snap_result = None; self.tabs[i].scene.deselect_all(); self.tabs[i].scene.clear_preview_wire(); self.refresh_area_preview(i); self.refresh_properties(); if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()) { self.command_line.push_info(&prompt); } } fn handle_undo_document(&mut self) { let i = self.active_tab; let active = self.tabs[i].active_cmd.take(); self.undo_active_tab(); self.tabs[i].active_cmd = active; if self.tabs[i] .active_cmd .as_ref() .is_some_and(|command| command.name() == "PEDIT") { let entities: Vec<_> = self.tabs[i].scene.document.entities().cloned().collect(); if let Some(command) = self.tabs[i].active_cmd.as_mut() { for entity in entities { command.inject_picked_entity(entity); } } } { let tab = &mut self.tabs[i]; if let Some(command) = tab.active_cmd.as_mut() { command.on_document_undone(&tab.scene.document); } } let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { self.command_line.push_info(&p); } } fn handle_return_point(&mut self) { let i = self.active_tab; self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); self.restore_pre_cmd_tangent(); } // --- C2a handlers: group / table (thin-router targets, bodies moved verbatim) --- fn handle_create_group( &mut self, mut handles: Vec, name: String, ) -> Option> { let i = self.active_tab; handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); if handles.is_empty() { return Some(Task::none()); } let undo = self.begin_group_undo(i, "GROUP"); self.tabs[i].scene.create_group(name.clone(), handles); self.tabs[i].dirty = true; self.commit_group_undo(i, undo); self.command_line .push_info(crate::tf!("Group \"{}\" created.", name).as_ref()); None } fn handle_delete_groups(&mut self, mut handles: Vec) -> Option> { let i = self.active_tab; handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); if handles.is_empty() { return Some(Task::none()); } let undo = self.begin_group_undo(i, "UNGROUP"); let count = self.tabs[i].scene.delete_groups_containing(&handles); self.tabs[i].dirty = true; self.commit_group_undo(i, undo); if count > 0 { self.command_line .push_info(crate::tf!("{} group(s) dissolved.", count).as_ref()); } else { self.command_line .push_info(crate::t!("No groups found for selected objects.").as_ref()); } None } fn handle_edit_table_cell( &mut self, handle: Handle, point: glam::DVec3, ) -> Option> { let i = self.active_tab; // TABLEDIT's pick: end the pick phase and hand (table, point) // to the shared cell-edit launcher. A miss or a locked cell // re-prompts until a valid table cell is selected. use crate::modules::annotate::table_cmd::{table_cell_at, TableCellEditStart}; self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); self.restore_pre_cmd_tangent(); if handle == codec::Handle::NULL && self.selection_cycling { let mut candidates = Vec::new(); for entity in self.tabs[i].scene.document.entities() { if let codec::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 { codec::objects::ObjectType::TableStyle(style) => Some(style), _ => None, }) }); if table_cell_at(table, style, point).is_some() { candidates.push(h); } } } if candidates.len() >= 2 { self.cycle_candidates = Some((self.quick_properties_anchor, candidates)); return Some(Task::none()); } } match self.begin_table_cell_edit(i, handle, point) { TableCellEditStart::Started => {} TableCellEditStart::LockedCell => { self.command_line .push_error(crate::t!("The cell is content locked.").as_ref()); let _ = self.dispatch_command("TABLEDIT"); } TableCellEditStart::NoCell => { self.command_line .push_error(crate::t!("No editable table cell picked.").as_ref()); let _ = self.dispatch_command("TABLEDIT"); } } None } // --- C2a handlers: mleader (thin-router targets, bodies moved verbatim) --- fn handle_set_quick_dimension_snap_priority(&mut self, priority: u8) { let i = self.active_tab; self.quick_dimension_snap_priority = priority.min(1); self.persist_settings_if_changed(); let prompt = self.tabs[i] .active_cmd .as_ref() .map(|command| command.prompt()); if let Some(prompt) = prompt { self.command_line.push_info(&prompt); } } fn handle_align_mleaders( &mut self, mut handles: Vec, from: glam::DVec3, to: glam::DVec3, ) { let i = self.active_tab; handles.retain(|handle| { !self.tabs[i].scene.is_layer_locked(*handle) && matches!( self.tabs[i].scene.document.get_entity(*handle), Some(codec::EntityType::MultiLeader(_)) ) }); if !handles.is_empty() { self.push_undo_snapshot(i, "MLEADERALIGN"); if apply_mleader_align(&mut self.tabs[i].scene, &handles, from, to) { self.tabs[i].dirty = true; self.command_line .push_output(crate::t!("MLEADERALIGN Leaders aligned.").as_ref()); } } self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.restore_pre_cmd_tangent(); } fn handle_collect_mleaders(&mut self, handles: Vec, point: glam::DVec3) { let i = self.active_tab; let compatible = compatible_mleader_collect_handles(&self.tabs[i].scene, &handles); if compatible.len() >= 2 { self.push_undo_snapshot(i, "MLEADERCOLLECT"); if apply_mleader_collect(&mut self.tabs[i].scene, &compatible, point) { self.tabs[i].dirty = true; self.command_line .push_output(crate::t!("MLEADERCOLLECT Leaders collected.").as_ref()); } } self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.restore_pre_cmd_tangent(); } // --- C2a handlers: property_match (thin-router targets, bodies moved verbatim) --- // --- C2a handlers: viewport (thin-router targets, bodies moved verbatim) --- // --- C2a handlers: text_edit (thin-router targets, bodies moved verbatim) --- // --- C2b handlers: entity_commit (thin-router targets, bodies moved verbatim) --- // --- C2b handlers: clipboard (thin-router targets, bodies moved verbatim) --- /// Restore the tangent-snap / ortho state that was in effect before the command started. /// Recreate clipboard-dependency records (layer / linetype / text + dim /// style) in tab `i`'s document for any the copied entities reference but /// this drawing doesn't already have. Each recreated record gets a fresh /// handle from the target document so it can't collide with an existing /// one. No-op for same-document pastes (the records already exist). (#129) pub(super) fn merge_dependencies(&mut self, i: usize, deps: &crate::app::ClipboardDeps) { if deps.is_empty() { return; } // Single shared copy — see `clipboard::merge_table_records`. clipboard::merge_table_records(&mut self.tabs[i].scene.document, deps); } // --- C2d handlers: constraint (thin-router targets, bodies moved verbatim) --- // --- C2d handlers: dim_edit (thin-router targets, bodies moved verbatim) --- pub(super) fn restore_pre_cmd_tangent(&mut self) { if let Some(was_on) = self.pre_cmd_tangent.take() { if !was_on { self.snapper.enabled.remove(&crate::snap::SnapType::Tangent); } } if self.rect_suppressed_ortho { self.rect_suppressed_ortho = false; self.ortho_mode = true; self.polar_mode = false; } } } /// Clone one captured xdictionary subtree into `doc` with fresh handles, /// remapping every internal reference (and the owning entity, when known), /// returning the new root handle. `allocate_handle` advances the document's /// handle counter — `next_handle()` only peeks, so reusing it would hand every /// object the same handle and collapse the dictionary chain. /// The entity a typed coordinate lands on while an object is asked for: /// the nearest planar curve of the edited space within a small share of /// that space's extent, the way a pick box takes the object under a click. fn entity_at_typed_point( document: &codec::CadDocument, owner: Handle, point: glam::DVec3, ) -> Option { let mut extent = 0.0f64; let mut nearest: Option<(f64, Handle)> = None; for entity in document.entities() { let common = entity.common(); if common.owner_handle != owner { continue; } let bounds = entity.as_entity().bounding_box(); extent = extent .max((bounds.max.x - bounds.min.x).abs()) .max((bounds.max.y - bounds.min.y).abs()); let distance = match crate::scene::viewport_dimension_pick::planar_pick_distance(entity, point) { Some(distance) => distance, // A text or an ellipse answers by its extent, the way a click // inside it picks it. None if matches!( entity, codec::EntityType::Text(_) | codec::EntityType::MText(_) | codec::EntityType::Ellipse(_) ) => { let dx = (bounds.min.x - point.x) .max(point.x - bounds.max.x) .max(0.0); let dy = (bounds.min.y - point.y) .max(point.y - bounds.max.y) .max(0.0); dx.hypot(dy) } None => continue, }; if nearest.is_none_or(|(best, _)| distance < best) { nearest = Some((distance, common.handle)); } } let tolerance = (extent * 0.002).max(1.0e-9); nearest .filter(|(distance, _)| *distance <= tolerance) .map(|(_, handle)| handle) } // ── DIMSPACE helper ─────────────────────────────────────────────────────────── fn apply_mleader_align( scene: &mut crate::scene::Scene, handles: &[codec::Handle], from: glam::DVec3, to: glam::DVec3, ) -> bool { use kernel::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(codec::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: &[codec::Handle], ) -> Vec { let Some((base_block, base_style)) = handles.iter().find_map(|handle| { if scene.is_layer_locked(*handle) { return None; } let codec::EntityType::MultiLeader(leader) = scene.document.get_entity(*handle)? else { return None; }; (leader.content_type == codec::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(codec::EntityType::MultiLeader(leader)) if leader.content_type == codec::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: &[codec::Handle], point: glam::DVec3, ) -> bool { if handles.len() < 2 { return false; } let px = point.x; let py = point.y; let Some(codec::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(codec::EntityType::MultiLeader(ml)) = scene.document.get_entity(h) { if ml.content_type == codec::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(codec::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 }