use super::{Message, OpenCADStudio};
use crate::command::{CmdResult, StepInput};
use acadrust::Handle;
use iced::Task;

impl OpenCADStudio {
    /// 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("Outside limits.");
            return false;
        }
        true
    }

    /// 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<Message> {
        // 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;
        if let StepInput::Point(point) = &input {
            if !self.command_point_allowed(i, *point) {
                return Task::none();
            }
        }
        let ctrl = self.ctrl_down;
        let shift = self.shift_down;
        let result: Option<CmdResult> = {
            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<Message> {
        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<String> = tokens.iter().map(|s| s.to_string()).collect();
            self.finish_active_command(&toks);
            return Task::none();
        }
        let _ = 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<String> = tokens.iter().map(|s| s.to_string()).collect();
        self.finish_active_command(&toks);
        Task::none()
    }

    /// 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]) {
        let i = self.active_tab;
        if self.tabs[i].active_cmd.is_none() {
            return;
        }
        self.last_point = None;
        for tok in &tokens[1..] {
            if self.tabs[i].active_cmd.is_none() {
                break;
            }
            self.feed_active_cmd(tok);
        }
        let _ = self.feed_command(StepInput::Enter);
    }

    /// 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<Task<Message>> {
        let i = self.active_tab;
        let gathering = self.tabs[i]
            .active_cmd
            .as_ref()
            .map_or(false, |c| c.is_selection_gathering());
        if !gathering {
            return None;
        }
        let kw = text.trim().to_ascii_uppercase();
        let add: Vec<Handle> = match kw.as_str() {
            "P" | "PREVIOUS" => self.tabs[i]
                .prev_selection
                .iter()
                .copied()
                .filter(|&h| {
                    self.tabs[i].scene.document.get_entity(h).is_some()
                        && !self.tabs[i].scene.is_layer_locked(h)
                })
                .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))
                .filter(|&h| !self.tabs[i].scene.is_layer_locked(h))
                .max_by_key(|h| h.value())
                .into_iter()
                .collect(),
            _ => return None,
        };
        if add.is_empty() {
            self.command_line.push_info(match kw.as_str() {
                "P" | "PREVIOUS" => "No previous selection set.",
                _ => "No last object.",
            });
            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(&format!("{count} object(s) added to selection."));
        self.refresh_properties();
        let handles: Vec<Handle> = self.tabs[i]
            .scene
            .selected_entities()
            .into_iter()
            .map(|(h, _)| h)
            .collect();
        let result = self.tabs[i]
            .active_cmd
            .as_mut()
            .map(|cmd| cmd.on_selection_complete(handles));
        Some(match result {
            Some(r) => self.apply_cmd_result(r),
            None => Task::none(),
        })
    }

    pub(super) fn feed_active_cmd(&mut self, token: &str) {
        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 {
                let _ = self.apply_cmd_result(r);
                return;
            }
            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);
                let _ = self.feed_command(StepInput::EntityPick(handle, pt.as_dvec3()));
            }
            return;
        }
        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;
            }
            self.last_point = Some(wcs);
            self.push_ucs_to_cmd(i);
            let _ = self.feed_command(StepInput::Point(wcs));
        } else {
            let _ = 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, result: CmdResult) -> Task<Message> {
        let was_active = self.tabs[self.active_tab].active_cmd.is_some();
        let preserve_selection =
            matches!(result, CmdResult::Relaunch(..) | CmdResult::Dispatch(..));
        let task = self.apply_cmd_result_inner(result);
        let i = self.active_tab;
        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.restore_add_selected_defaults();
        }
        task
    }

    fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task<Message> {
        let i = self.active_tab;
        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.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);
                }
                // 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();
            }
            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) => {
                // 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(_)
                ) {
                    self.update_cont_anchor(&entity);
                }
                let label = self.history_label_from_active_cmd(i, "ENTITY");
                // A plain drawable added on an existing layer touches only the
                // one new entity; a block/image/dimension/viewport add also
                // mutates layers/objects/blocks → keep the full snapshot.
                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;
                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(entities) => {
                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);
                for entity in entities {
                    self.commit_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);
                }
                if let Some(pd) = pending {
                    self.commit_undo_delta(i, pd);
                }
            }
            CmdResult::TransformSelected(handles, transform) => {
                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);
                // ACIS solids render from a cached mesh, so a move/rotate/
                // scale/mirror needs the mesh re-tessellated from the now-moved
                // body — wire re-tessellation alone leaves the solid drawn at
                // its old spot. (#135)
                if self.tabs[i].scene.any_solid(&handles) {
                    self.tabs[i].scene.refresh_meshes_for_handles(&handles);
                }
                self.tabs[i].dirty = true;
                self.tabs[i].scene.clear_preview_wire();
                self.tabs[i].active_cmd = None;
                self.tabs[i].snap_result = None;
                self.restore_pre_cmd_tangent();
                self.refresh_properties();
                if let Some(p) = pending {
                    self.commit_undo_delta(i, p);
                }
            }
            CmdResult::CopySelected(handles, transform) => {
                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);
                if self.tabs[i].scene.any_solid(&new_handles) {
                    self.tabs[i].scene.refresh_meshes_for_handles(&new_handles);
                }
                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::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<String> = {
                    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();
                }
                // 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);
                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::CommitSolid { entity, solid } => {
                let label = self.history_label_from_active_cmd(i, "SOLID");
                let pending = self.begin_undo(i, label, 1, true);
                self.add_solid_model(entity, *solid);
                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::CommitAndEditText(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();
                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,
            } => {
                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) {
                    if let Some(acadrust::EntityType::Leader(l)) =
                        self.tabs[i].scene.document.get_entity_mut(lh)
                    {
                        l.annotation_handle = ah;
                    }
                }
                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) = edit_handle {
                    return self.begin_text_edit(h);
                }
            }
            CmdResult::CreateBlock {
                handles,
                name,
                base,
            } => {
                self.push_undo_snapshot(i, "BLOCK");
                match self.tabs[i]
                    .scene
                    .create_block_from_entities(&handles, &name, base)
                {
                    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(&format!("Block \"{name}\" created."));
                        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 new_handle = self.tabs[i].scene.add_hatch(hatch);
                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(handles, transforms) => {
                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(&format!("{label}: {count} {noun} created."));
                self.refresh_properties();
                if let Some(pd) = pending {
                    self.commit_undo_delta(i, pd);
                }
            }
            CmdResult::ReplaceMany(replacements, additions) => {
                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.tabs[i].scene.erase_entities(&[handle]);
                    for entity in entities {
                        let nh = self.tabs[i].scene.add_entity(entity);
                        // Drop a stale *D block on any replaced dimension (#181).
                        if matches!(
                            self.tabs[i].scene.document.get_entity(nh),
                            Some(acadrust::EntityType::Dimension(_))
                        ) {
                            self.tabs[i].scene.invalidate_dim_block_recorded(nh);
                        }
                    }
                }
                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("Catchment tagged successfully.");
                }
                self.refresh_properties();
            }
            CmdResult::ReplaceEntity(handle, new_entities) => {
                // 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();
                        }
                    }
                }
                // Detect DIMBREAK sentinel.
                if new_entities.len() == 1 {
                    if let acadrust::EntityType::XLine(ref xl) = new_entities[0] {
                        let layer = xl.common.layer.clone();
                        if layer.starts_with("__DIMBREAK__")
                            || layer.starts_with("__DIMBREAK_AUTO__")
                        {
                            // DIMBREAK needs a break-gap field on the dimension
                            // model (not yet present) to store and render the gap.
                            // Report honestly rather than claiming success while
                            // changing nothing. (#181 / DIM-020)
                            self.command_line
                                .push_info("DIMBREAK: not yet implemented — nothing changed.");
                            self.tabs[i].active_cmd = None;
                            self.tabs[i].snap_result = None;
                            return Task::none();
                        }
                        if layer.starts_with("__DIMSPACE__") {
                            if let Some(encoded) = layer.strip_prefix("__DIMSPACE__") {
                                apply_dimspace(&mut self.tabs[i].scene, encoded);
                            }
                            self.push_undo_snapshot(i, "DIMSPACE");
                            self.command_line.push_output("DIMSPACE  Spacing adjusted.");
                            self.tabs[i].dirty = true;
                            self.tabs[i].active_cmd = None;
                            self.tabs[i].snap_result = None;
                            return Task::none();
                        }
                        if layer.starts_with("__DIMJOG__") {
                            // DIMJOGLINE needs a jog-point field on the dimension
                            // model (not yet present) to store and render the jog.
                            // Report honestly rather than faking success. (DIM-019)
                            self.command_line
                                .push_info("DIMJOGLINE: not yet implemented — nothing changed.");
                            self.tabs[i].active_cmd = None;
                            self.tabs[i].snap_result = None;
                            return Task::none();
                        }
                        if layer.starts_with("__MLEADERALIGN__") {
                            if let Some(encoded) = layer.strip_prefix("__MLEADERALIGN__") {
                                apply_mleader_align(&mut self.tabs[i].scene, encoded);
                            }
                            self.push_undo_snapshot(i, "MLEADERALIGN");
                            self.command_line
                                .push_output("MLEADERALIGN  Leaders aligned.");
                            self.tabs[i].dirty = true;
                            self.tabs[i].active_cmd = None;
                            self.tabs[i].snap_result = None;
                            return Task::none();
                        }
                        if layer.starts_with("__MLEADERCOLLECT__") {
                            if let Some(encoded) = layer.strip_prefix("__MLEADERCOLLECT__") {
                                apply_mleader_collect(&mut self.tabs[i].scene, encoded);
                            }
                            self.push_undo_snapshot(i, "MLEADERCOLLECT");
                            self.command_line
                                .push_output("MLEADERCOLLECT  Leaders collected.");
                            self.tabs[i].dirty = true;
                            self.tabs[i].active_cmd = None;
                            self.tabs[i].snap_result = None;
                            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<acadrust::Handle> = new_entities
                    .into_iter()
                    .map(|e| self.tabs[i].scene.add_entity(e))
                    .collect();
                // A replaced dimension carries edited geometry/text but still
                // names its old *D block; drop that stale block so the next save
                // re-bakes it — otherwise BricsCAD/ODA draw the pre-edit
                // graphics while OCS shows the edit. (#181)
                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);
                    }
                }
                if let Some(cmd) = &mut self.tabs[i].active_cmd {
                    cmd.on_entity_replaced(handle, &new_handles);
                }
                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<acadrust::entities::AttributeDefinition> = {
                    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 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;
                        self.restore_pre_cmd_tangent();
                        if let Some(pd) = pending {
                            self.commit_undo_delta(i, pd);
                        }
                    }
                } else {
                    // Inject attdefs so the command enters attr-filling mode.
                    if let Some(cmd) = &mut self.tabs[i].active_cmd {
                        cmd.attreq_set_attdefs(attdefs);
                    }
                    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,
            } => {
                // 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 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::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]);
                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);
                }
            }
            CmdResult::Cancel => {
                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_info("Command cancelled.");
            }
            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::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());
                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(&format!("Layer matched to \"{layer}\"."));
                    self.sync_ribbon_layers();
                } else {
                    self.command_line.push_error("Source object not found.");
                }
            }
            CmdResult::MatchProperties { dest, src } => {
                // The command stays active after each apply so more targets
                // can keep being picked; Enter / Esc ends it (#362).
                let props = self.tabs[i].scene.document.get_entity(src).map(|e| {
                    let c = e.common();
                    (
                        c.layer.clone(),
                        c.color,
                        c.linetype.clone(),
                        c.linetype_scale,
                        c.line_weight,
                    )
                });
                // Special (type-specific) properties travel like AutoCAD's
                // Special Properties: each is captured from the source when it
                // carries it and applied only to destinations that support it
                // (#281). Text formatting crosses TEXT ↔ MTEXT (#361); the dim
                // style crosses Dimension / Leader / Tolerance.
                let src_clone = self.tabs[i].scene.document.get_entity(src).cloned();
                let transparency = src_clone.as_ref().map(|e| e.common().transparency.clone());
                // 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<Vec<(i16, acadrust::xdata::XDataValue)>> = 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((layer, color, linetype, lt_scale, lw)) = props {
                    self.push_undo_snapshot(i, "MATCHPROP");
                    let mut any_dim = false;
                    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());
                            crate::scene::view::dispatch::apply_color(e, color);
                            crate::scene::view::dispatch::apply_line_weight(e, lw);
                            e.common_mut().linetype = linetype.clone();
                            e.common_mut().linetype_scale = lt_scale;
                            if let Some(t) = &transparency {
                                e.common_mut().transparency = t.clone();
                            }
                            if let Some(se) = &src_clone {
                                if matches!(e, acadrust::EntityType::Dimension(_)) {
                                    any_dim = true;
                                }
                                match_special_props(se, e);
                            }
                        }
                        // 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!(
                                self.tabs[i].scene.document.get_entity(*h),
                                Some(
                                    acadrust::EntityType::Dimension(_)
                                        | acadrust::EntityType::Leader(_)
                                )
                            )
                        {
                            if matches!(
                                self.tabs[i].scene.document.get_entity(*h),
                                Some(
                                    acadrust::EntityType::Dimension(_)
                                        | acadrust::EntityType::Leader(_)
                                )
                            ) && matches!(
                                src_clone,
                                Some(
                                    acadrust::EntityType::Dimension(_)
                                        | acadrust::EntityType::Leader(_)
                                )
                            ) {
                                crate::entities::dim_override::replace(
                                    &mut self.tabs[i].scene.document,
                                    *h,
                                    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 any_dim {
                            self.tabs[i].scene.invalidate_dim_block_recorded(*h);
                        }
                        // Hatch fills render from a prebuilt model (#415).
                        self.tabs[i].scene.refresh_fill_model(*h);
                    }
                    self.tabs[i].dirty = true;
                    // Color / linetype / lineweight are baked into the cached
                    // wires at tessellation time; bump the geometry epoch so the
                    // matched objects repaint instead of holding their old look.
                    self.tabs[i].scene.bump_geometry();
                    self.refresh_properties();
                    self.command_line
                        .push_info(&format!("Properties matched to {} object(s).", dest.len()));
                    // 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("Source object not found.");
                    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("Clipboard is empty.");
                } 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(&format!("{count} object(s) pasted."));
                }
            }
            CmdResult::CreateGroup { handles, name } => {
                self.tabs[i].active_cmd = None;
                self.tabs[i].snap_result = None;
                self.tabs[i].scene.clear_preview_wire();
                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(&format!("Group \"{}\" created.", name));
            }
            CmdResult::DeleteGroups { handles } => {
                self.tabs[i].active_cmd = None;
                self.tabs[i].snap_result = None;
                self.tabs[i].scene.clear_preview_wire();
                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(&format!("{} group(s) dissolved.", count));
                } else {
                    self.command_line
                        .push_info("No groups found for selected objects.");
                }
            }
            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<Handle> = 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<Handle> = 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<Handle> = 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(&format!(
                            "VPLAYER: {frozen_count} layer(s) frozen in viewport."
                        ));
                    }
                    if thawed_count > 0 {
                        self.command_line.push_info(&format!(
                            "VPLAYER: {thawed_count} layer(s) thawed in viewport."
                        ));
                    }
                    // 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
                    .zoom_to_window(p1.as_vec3(), p2.as_vec3());
                self.command_line.push_output("Zoom Window");
            }
            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::AlignSelected {
                handles,
                src1,
                dst1,
                angle_rad,
                scale,
            } => {
                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,
                                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("ALIGN: applied.");
                    self.refresh_properties();
                    if let Some(pd) = pending {
                        self.commit_undo_delta(i, pd);
                    }
                }
            }
            CmdResult::LengthenEntity {
                handle,
                pick_pt,
                mode,
            } => {
                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("LENGTHEN: applied.");
                        self.refresh_properties();
                    }
                    None => {
                        self.command_line
                            .push_error("LENGTHEN: entity type not supported.");
                    }
                }
                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 } => {
                use crate::modules::draw::inquiry::divide::divide_entity;
                let pts = self.tabs[i]
                    .scene
                    .document
                    .get_entity(handle)
                    .map(|e| divide_entity(e, n))
                    .unwrap_or_default();
                let count = pts.len();
                if count > 0 {
                    self.push_undo_snapshot(i, "DIVIDE");
                    for p in pts {
                        self.tabs[i].scene.add_entity(p);
                    }
                    self.tabs[i].dirty = true;
                    self.command_line
                        .push_output(&format!("DIVIDE: {count} point(s) placed."));
                } else {
                    self.command_line
                        .push_error("DIVIDE: entity type not supported or N < 2.");
                }
                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,
            } => {
                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))
                    .unwrap_or_default();
                let count = pts.len();
                if count > 0 {
                    self.push_undo_snapshot(i, "MEASURE");
                    for p in pts {
                        self.tabs[i].scene.add_entity(p);
                    }
                    self.tabs[i].dirty = true;
                    self.command_line
                        .push_output(&format!("MEASURE: {count} point(s) placed."));
                } else {
                    self.command_line
                        .push_error("MEASURE: entity type not supported or distance too large.");
                }
                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 } => {
                use crate::modules::draw::modify::pedit::{
                    apply_pedit, convert_to_polyline, PeditOp,
                };
                match &op {
                    // The convert replaces the entity (new handle).
                    PeditOp::ConvertToPolyline => {
                        let converted = self.tabs[i]
                            .scene
                            .document
                            .get_entity(handle)
                            .and_then(convert_to_polyline);
                        match converted {
                            Some(pl) => {
                                return self
                                    .apply_cmd_result(CmdResult::ReplaceEntity(handle, vec![pl]));
                            }
                            None => self
                                .command_line
                                .push_error("PEDIT: cannot convert this entity."),
                        }
                    }
                    _ => {
                        // Snapshot BEFORE the mutation — an after-the-fact
                        // snapshot records the changed state and undo no-ops.
                        self.push_undo_snapshot(i, "PEDIT");
                        let changed = self.tabs[i]
                            .scene
                            .document
                            .get_entity_mut(handle)
                            .map(|e| apply_pedit(e, &op))
                            .unwrap_or(false);
                        if changed {
                            self.tabs[i].dirty = true;
                            // Repaint immediately (wide-band fill included).
                            self.tabs[i].scene.mark_entity_dirty(handle);
                            self.tabs[i].scene.refresh_fill_model(handle);
                            self.tabs[i].scene.bump_geometry_no_blocks();
                            self.command_line.push_output("PEDIT: applied.");
                            self.refresh_properties();
                        } else {
                            self.discard_last_undo_entry(i);
                            self.command_line
                                .push_error("PEDIT: operation not applicable to this 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::JoinEntities(handles) => {
                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(&format!(
                            "JOIN: {count_in} object(s) joined into {count_out}."
                        ));
                        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(
                            "JOIN: objects don't form a single connected chain, or contain an unsupported type / tilted arc.",
                        );
                    }
                }
            }
            CmdResult::BreakEntity { handle, p1, p2 } => {
                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 label = self.history_label_from_active_cmd(i, "BREAK");
                        self.push_undo_snapshot(i, label);
                        self.tabs[i].scene.erase_entities(&[handle]);
                        let count = frags.len();
                        for e in frags {
                            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(&format!("BREAK: {} fragment(s).", count));
                        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("BREAK: entity type not supported.");
                    }
                }
            }
            CmdResult::SetPlotWindow { p1, p2 } => {
                use acadrust::objects::{ObjectType, PlotSettings};
                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(&format!("Plot window: {x0:.2},{y0:.2} to {x1:.2},{y1:.2}"));
                    // 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 {
                    let block_handle = self.tabs[i].scene.current_layout_block_handle_pub();
                    let doc = &mut self.tabs[i].scene.document;
                    let ps_handle = doc.objects.iter().find_map(|(h, obj)| {
                        if let ObjectType::PlotSettings(ps) = obj {
                            if ps.page_name == layout_name {
                                Some(*h)
                            } else {
                                None
                            }
                        } else {
                            None
                        }
                    });
                    let ps_entry = match ps_handle {
                        Some(h) => doc.objects.get_mut(&h),
                        None => {
                            let nh = acadrust::Handle::new(doc.next_handle());
                            let ps = PlotSettings::new(layout_name.clone());
                            doc.objects.insert(nh, ObjectType::PlotSettings(ps));
                            doc.objects.get_mut(&nh)
                        }
                    };
                    let _ = block_handle;
                    if let Some(ObjectType::PlotSettings(ps)) = ps_entry {
                        // Convert world-space points to DXF coordinates (X, Z plane → DXF X, Y).
                        let x1 = p1.x.min(p2.x) as f64;
                        let y1 = p1.z.min(p2.z) as f64;
                        let x2 = p1.x.max(p2.x) as f64;
                        let y2 = p1.z.max(p2.z) as f64;
                        ps.set_plot_window(x1, y1, x2, y2);
                        self.push_undo_snapshot(i, "PLOTWINDOW");
                        self.tabs[i].dirty = true;
                        self.command_line.push_output(&format!(
                            "PLOTWINDOW: ({x1:.3},{y1:.3}) → ({x2:.3},{y2:.3})"
                        ));
                    }
                }
                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 { win_min, win_max } => {
                // Implicit STRETCH selection (#338): the crossing window drawn
                // with no prior selection picks the objects itself. Entities
                // whose world AABB touches the window are handed back to the
                // command at the base-point step — over-selection is harmless,
                // since only points INSIDE the window move anyway.
                let mut handles: Vec<Handle> = Vec::new();
                {
                    let scene = &self.tabs[i].scene;
                    handles.extend(
                        scene
                            .interaction_handles_in_world_aabb([
                                win_min.x, win_min.y, win_max.x, win_max.y,
                            ])
                            .into_iter()
                            .filter(|&h| !scene.is_layer_locked(h)),
                    );
                }
                if handles.is_empty() {
                    self.command_line
                        .push_output("STRETCH: nothing crosses the window.");
                    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 {
                    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_window(handles, wires, win_min, win_max);
                    self.command_line.push_info(&CadCommand::prompt(&cmd));
                    self.tabs[i].active_cmd = Some(Box::new(cmd));
                }
            }
            CmdResult::StretchEntities {
                handles,
                win_min,
                win_max,
                delta,
            } => {
                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 {
                    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<acadrust::Handle> = 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) => {
                            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::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 =
                                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.definition_point);
                                    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 {
                    if structural {
                        self.tabs[i].scene.bump_geometry();
                    } else {
                        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(&format!("STRETCH: {count} entity(ies) stretched."));
                self.refresh_properties();
                if let Some(pending) = pending {
                    self.commit_undo_delta(i, pending);
                }
            }
            // ── Solid3D creation (BOX / SPHERE / CYLINDER) ────────────────
            CmdResult::CommitSolid3D { mesh_fn } => {
                use crate::modules::insert::solid3d_cmds::empty_solid3d;
                let pending = self.begin_undo(i, "SOLID3D", 1, true);
                let entity = empty_solid3d();
                let handle = self.tabs[i].scene.add_entity(entity);
                if !handle.is_null() {
                    let name = format!("{}", handle.value());
                    let color = [0.6f32, 0.6, 0.8, 1.0]; // default colour; command embedded it
                    let _ = color; // color is captured inside mesh_fn
                    if let Some(mesh) = mesh_fn(name) {
                        self.tabs[i]
                            .scene
                            .meshes
                            .insert(handle, crate::scene::MeshLodSet::from_single(mesh));
                    }
                    self.tabs[i].dirty = true;
                    self.command_line.push_output("Solid created.");
                }
                if let Some(pd) = pending {
                    self.commit_undo_delta(i, pd);
                }
                self.tabs[i].active_cmd = None;
                self.tabs[i].snap_result = None;
                self.tabs[i].scene.clear_preview_wire();
                self.restore_pre_cmd_tangent();
            }

            // ── EXTRUDE ────────────────────────────────────────────────────
            CmdResult::ExtrudeEntity {
                handle,
                height,
                color,
            } => {
                use crate::entities::traits::EntityTypeOps;
                use crate::modules::insert::solid3d_cmds::empty_solid3d;
                use crate::scene::convert::acad_to_truck::TruckObject;
                use crate::scene::convert::truck_tess;
                use truck_modeling::builder;
                use truck_modeling::Vector3 as TruckVec3;

                let entity_opt = self.tabs[i].scene.document.get_entity(handle).cloned();
                if let Some(entity) = entity_opt {
                    let truck_entity = entity.to_truck_entity(&self.tabs[i].scene.document);
                    let result = truck_entity.and_then(|te| {
                        match te.object {
                            TruckObject::Contour(wire) => {
                                // Attach a planar face to the wire profile, then sweep.
                                let face = builder::try_attach_plane(&[wire]).ok()?;
                                // tsweep(Face) → Solid
                                let solid =
                                    builder::tsweep(&face, TruckVec3::new(0.0, 0.0, height as f64));
                                match truck_tess::tessellate_solid(&solid) {
                                    truck_tess::TruckTessResult::Mesh {
                                        verts,
                                        verts_low,
                                        normals,
                                        indices,
                                    } => Some((
                                        crate::scene::model::mesh_model::MeshModel {
                                            name: String::new(),
                                            verts,
                                            verts_low,
                                            normals,
                                            indices,
                                            triangle_material_handles: Vec::new(),
                                            triangle_colors: Vec::new(),
                                            color,
                                            selected: false,
                                        },
                                        solid,
                                    )),
                                    _ => None,
                                }
                            }
                            _ => None,
                        }
                    });
                    if let Some((mut mesh, solid)) = result {
                        let pending = self.begin_undo(i, "EXTRUDE", 1, true);
                        let new_entity = empty_solid3d();
                        let new_handle = self.tabs[i].scene.add_entity(new_entity);
                        mesh.name = format!("{}", new_handle.value());
                        self.tabs[i]
                            .scene
                            .meshes
                            .insert(new_handle, crate::scene::MeshLodSet::from_single(mesh));
                        // Keep the truck B-rep so the save path can export exact
                        // ACIS geometry (else the solid is dropped by other CAD apps).
                        self.tabs[i].scene.solid_models.insert(new_handle, solid);
                        self.tabs[i].dirty = true;
                        self.command_line.push_output("EXTRUDE: solid created.");
                        if let Some(pd) = pending {
                            self.commit_undo_delta(i, pd);
                        }
                    } else {
                        self.command_line.push_error("EXTRUDE: could not build profile. Select a closed 2D entity (Circle, LwPolyline, etc.).");
                    }
                } else {
                    self.command_line.push_error("EXTRUDE: entity not found.");
                }
                self.tabs[i].active_cmd = None;
                self.tabs[i].snap_result = None;
                self.tabs[i].scene.clear_preview_wire();
                self.restore_pre_cmd_tangent();
            }

            // ── REVOLVE ────────────────────────────────────────────────────
            CmdResult::RevolveEntity {
                handle,
                axis_start,
                axis_end,
                angle_deg,
                color,
            } => {
                use crate::entities::traits::EntityTypeOps;
                use crate::modules::insert::solid3d_cmds::empty_solid3d;
                use crate::scene::convert::acad_to_truck::TruckObject;
                use crate::scene::convert::truck_tess;
                use truck_modeling::builder;
                use truck_modeling::{Point3, Rad, Vector3 as TruckVec3};

                let entity_opt = self.tabs[i].scene.document.get_entity(handle).cloned();
                if let Some(entity) = entity_opt {
                    let truck_entity = entity.to_truck_entity(&self.tabs[i].scene.document);
                    let result = truck_entity.and_then(|te| {
                        let wire: Option<truck_modeling::Wire> = match te.object {
                            TruckObject::Contour(w) => Some(w),
                            TruckObject::Curve(e) => Some(std::iter::once(e).collect()),
                            _ => None,
                        };
                        let wire = wire?;
                        let origin = Point3::new(
                            axis_start.x as f64,
                            axis_start.y as f64,
                            axis_start.z as f64,
                        );
                        let dir = (axis_end - axis_start).normalize();
                        let axis = TruckVec3::new(dir.x as f64, dir.y as f64, dir.z as f64);
                        let shell = builder::rsweep(
                            &wire,
                            origin,
                            axis,
                            Rad(angle_deg.to_radians() as f64),
                        );
                        match truck_tess::tessellate_shell(&shell) {
                            truck_tess::TruckTessResult::Mesh {
                                verts,
                                verts_low,
                                normals,
                                indices,
                            } => Some(crate::scene::model::mesh_model::MeshModel {
                                name: String::new(),
                                verts,
                                verts_low,
                                normals,
                                indices,
                                triangle_material_handles: Vec::new(),
                                triangle_colors: Vec::new(),
                                color,
                                selected: false,
                            }),
                            _ => None,
                        }
                    });
                    if let Some(mut mesh) = result {
                        let pending = self.begin_undo(i, "REVOLVE", 1, true);
                        let new_entity = empty_solid3d();
                        let new_handle = self.tabs[i].scene.add_entity(new_entity);
                        mesh.name = format!("{}", new_handle.value());
                        self.tabs[i]
                            .scene
                            .meshes
                            .insert(new_handle, crate::scene::MeshLodSet::from_single(mesh));
                        self.tabs[i].dirty = true;
                        self.command_line
                            .push_output(&format!("REVOLVE: solid created ({:.0}°).", angle_deg));
                        if let Some(pd) = pending {
                            self.commit_undo_delta(i, pd);
                        }
                    } else {
                        self.command_line
                            .push_error("REVOLVE: could not revolve profile.");
                    }
                } else {
                    self.command_line.push_error("REVOLVE: entity not found.");
                }
                self.tabs[i].active_cmd = None;
                self.tabs[i].snap_result = None;
                self.tabs[i].scene.clear_preview_wire();
                self.restore_pre_cmd_tangent();
            }

            // ── SWEEP ──────────────────────────────────────────────────────
            CmdResult::SweepEntity {
                profile_handle,
                path_handle,
                color,
            } => {
                use crate::entities::traits::EntityTypeOps;
                use crate::modules::insert::solid3d_cmds::empty_solid3d;
                use crate::scene::convert::acad_to_truck::TruckObject;
                use crate::scene::convert::truck_tess;
                use truck_modeling::builder;
                use truck_modeling::Vector3 as TruckVec3;

                let profile_ent = self.tabs[i]
                    .scene
                    .document
                    .get_entity(profile_handle)
                    .cloned();
                let path_ent = self.tabs[i].scene.document.get_entity(path_handle).cloned();

                let result = profile_ent.zip(path_ent).and_then(|(prof_e, path_e)| {
                    let prof_truck = prof_e.to_truck_entity(&self.tabs[i].scene.document)?;
                    let path_truck = path_e.to_truck_entity(&self.tabs[i].scene.document)?;

                    // Profile must be a wire (closed or open).
                    let profile_wire: truck_modeling::Wire = match prof_truck.object {
                        TruckObject::Contour(w) => w,
                        TruckObject::Curve(e) => std::iter::once(e).collect(),
                        _ => return None,
                    };

                    // Path determines the sweep operation.
                    let mesh = match path_truck.object {
                        // Linear path: translate profile along the line direction.
                        TruckObject::Curve(edge) => {
                            let p_start = edge.front().point();
                            let p_end = edge.back().point();
                            let dir = TruckVec3::new(
                                p_end.x - p_start.x,
                                p_end.y - p_start.y,
                                p_end.z - p_start.z,
                            );
                            // Try to build a face from the profile; if it's a closed
                            // wire we get a Solid, otherwise a Shell.
                            if let Ok(face) = builder::try_attach_plane(&[profile_wire.clone()]) {
                                let solid = builder::tsweep(&face, dir);
                                match truck_tess::tessellate_solid(&solid) {
                                    truck_tess::TruckTessResult::Mesh {
                                        verts,
                                        verts_low,
                                        normals,
                                        indices,
                                    } => Some(crate::scene::model::mesh_model::MeshModel {
                                        name: String::new(),
                                        verts,
                                        verts_low,
                                        normals,
                                        indices,
                                        triangle_material_handles: Vec::new(),
                                        triangle_colors: Vec::new(),
                                        color,
                                        selected: false,
                                    }),
                                    _ => None,
                                }
                            } else {
                                let shell = builder::tsweep(&profile_wire, dir);
                                match truck_tess::tessellate_shell(&shell) {
                                    truck_tess::TruckTessResult::Mesh {
                                        verts,
                                        verts_low,
                                        normals,
                                        indices,
                                    } => Some(crate::scene::model::mesh_model::MeshModel {
                                        name: String::new(),
                                        verts,
                                        verts_low,
                                        normals,
                                        indices,
                                        triangle_material_handles: Vec::new(),
                                        triangle_colors: Vec::new(),
                                        color,
                                        selected: false,
                                    }),
                                    _ => None,
                                }
                            }
                        }

                        // Contour path (polyline): sweep along the polyline using the
                        // first edge's direction as approximation (multi-segment sweep
                        // requires NURBS deformation — not supported here).
                        TruckObject::Contour(path_wire) => {
                            // Use start→end of the whole wire as translation vector.
                            let p_start = path_wire.front_vertex()?.point();
                            let p_end = path_wire.back_vertex()?.point();
                            let dir = TruckVec3::new(
                                p_end.x - p_start.x,
                                p_end.y - p_start.y,
                                p_end.z - p_start.z,
                            );
                            if let Ok(face) = builder::try_attach_plane(&[profile_wire.clone()]) {
                                let solid = builder::tsweep(&face, dir);
                                match truck_tess::tessellate_solid(&solid) {
                                    truck_tess::TruckTessResult::Mesh {
                                        verts,
                                        verts_low,
                                        normals,
                                        indices,
                                    } => Some(crate::scene::model::mesh_model::MeshModel {
                                        name: String::new(),
                                        verts,
                                        verts_low,
                                        normals,
                                        indices,
                                        triangle_material_handles: Vec::new(),
                                        triangle_colors: Vec::new(),
                                        color,
                                        selected: false,
                                    }),
                                    _ => None,
                                }
                            } else {
                                let shell = builder::tsweep(&profile_wire, dir);
                                match truck_tess::tessellate_shell(&shell) {
                                    truck_tess::TruckTessResult::Mesh {
                                        verts,
                                        verts_low,
                                        normals,
                                        indices,
                                    } => Some(crate::scene::model::mesh_model::MeshModel {
                                        name: String::new(),
                                        verts,
                                        verts_low,
                                        normals,
                                        indices,
                                        triangle_material_handles: Vec::new(),
                                        triangle_colors: Vec::new(),
                                        color,
                                        selected: false,
                                    }),
                                    _ => None,
                                }
                            }
                        }

                        _ => None,
                    };
                    mesh
                });

                if let Some(mut mesh) = result {
                    let pending = self.begin_undo(i, "SWEEP", 1, true);
                    let new_entity = empty_solid3d();
                    let new_handle = self.tabs[i].scene.add_entity(new_entity);
                    mesh.name = format!("{}", new_handle.value());
                    self.tabs[i]
                        .scene
                        .meshes
                        .insert(new_handle, crate::scene::MeshLodSet::from_single(mesh));
                    self.tabs[i].dirty = true;
                    self.command_line.push_output("SWEEP: solid created.");
                    if let Some(pd) = pending {
                        self.commit_undo_delta(i, pd);
                    }
                } else {
                    self.command_line.push_error("SWEEP: could not sweep profile along path. Use a closed 2D profile and a Line or Polyline path.");
                }
                self.tabs[i].active_cmd = None;
                self.tabs[i].snap_result = None;
                self.tabs[i].scene.clear_preview_wire();
                self.restore_pre_cmd_tangent();
            }

            // ── LOFT ───────────────────────────────────────────────────────
            CmdResult::LoftEntities { handles, color } => {
                use crate::entities::traits::EntityTypeOps;
                use crate::modules::insert::solid3d_cmds::empty_solid3d;
                use crate::scene::convert::acad_to_truck::TruckObject;
                use crate::scene::convert::truck_tess;
                use truck_modeling::builder;

                // Collect wires from each profile.
                let mut wires: Vec<truck_modeling::Wire> = Vec::new();
                for h in &handles {
                    if let Some(ent) = self.tabs[i].scene.document.get_entity(*h).cloned() {
                        if let Some(te) = ent.to_truck_entity(&self.tabs[i].scene.document) {
                            let wire = match te.object {
                                TruckObject::Contour(w) => Some(w),
                                TruckObject::Curve(e) => Some(std::iter::once(e).collect()),
                                _ => None,
                            };
                            if let Some(w) = wire {
                                wires.push(w);
                            }
                        }
                    }
                }

                let result: Option<crate::scene::model::mesh_model::MeshModel> = (|| {
                    if wires.len() < 2 {
                        return None;
                    }

                    // Build ruled shells between consecutive profile pairs.
                    let mut all_faces: Vec<truck_modeling::Face> = Vec::new();

                    for pair in wires.windows(2) {
                        let shell = builder::try_wire_homotopy(&pair[0], &pair[1]).ok()?;
                        for face in shell.into_iter() {
                            all_faces.push(face);
                        }
                    }

                    // Cap the first and last profiles if they are closed.
                    if let Ok(cap) = builder::try_attach_plane(&[wires.first()?.clone()]) {
                        all_faces.push(cap);
                    }
                    if let Ok(cap) = builder::try_attach_plane(&[wires.last()?.clone()]) {
                        all_faces.push(cap);
                    }

                    let shell = truck_modeling::Shell::from(all_faces);
                    match truck_tess::tessellate_shell(&shell) {
                        truck_tess::TruckTessResult::Mesh {
                            verts,
                            verts_low,
                            normals,
                            indices,
                        } => Some(crate::scene::model::mesh_model::MeshModel {
                            name: String::new(),
                            verts,
                            verts_low,
                            normals,
                            indices,
                            triangle_material_handles: Vec::new(),
                            triangle_colors: Vec::new(),
                            color,
                            selected: false,
                        }),
                        _ => None,
                    }
                })(
                );

                if let Some(mut mesh) = result {
                    let pending = self.begin_undo(i, "LOFT", 1, true);
                    let new_entity = empty_solid3d();
                    let new_handle = self.tabs[i].scene.add_entity(new_entity);
                    mesh.name = format!("{}", new_handle.value());
                    self.tabs[i]
                        .scene
                        .meshes
                        .insert(new_handle, crate::scene::MeshLodSet::from_single(mesh));
                    self.tabs[i].dirty = true;
                    self.command_line.push_output(&format!(
                        "LOFT: solid created from {} profiles.",
                        handles.len()
                    ));
                    if let Some(pd) = pending {
                        self.commit_undo_delta(i, pd);
                    }
                } else {
                    self.command_line.push_error("LOFT: could not loft profiles. Ensure sections have the same edge count and are compatible.");
                }
                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::HatcheditApply {
                handle,
                name,
                scale,
                angle,
            } => {
                if let Some(mut model) = self.tabs[i].scene.hatches.get(&handle).cloned() {
                    // Update model fields
                    if !name.is_empty() {
                        use crate::scene::model::hatch_model::HatchPattern;
                        use crate::scene::model::hatch_patterns;
                        model.name = name.clone();
                        if name.to_uppercase() == "SOLID" {
                            model.pattern = HatchPattern::Solid;
                        } else if let Some(entry) = hatch_patterns::find(&name) {
                            model.pattern = entry.gpu.clone();
                        }
                        // If not found in catalog, keep existing pattern type
                    }
                    model.scale = scale;
                    model.angle_offset = angle;

                    self.push_undo_snapshot(i, "HATCHEDIT");
                    // Remove old hatch (entity + GPU model)
                    self.tabs[i].scene.erase_entities(&[handle]);
                    // Re-add with updated model
                    self.tabs[i].scene.add_hatch(model);
                    self.tabs[i].dirty = true;
                    self.command_line.push_output("HATCHEDIT: hatch updated.");
                } else {
                    self.command_line
                        .push_error("HATCHEDIT: hatch entity not found.");
                }
                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,
            } => {
                self.tabs[i].active_cmd = None;
                self.tabs[i].snap_result = None;
                self.open_mtext_editor(pos, handle, &initial, height);
            }
            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,
                );
            }
            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("TEXTEDIT: selected entity is not text.");
                    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;
                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(&format!("TEXTEDITMODE set to {display_val}"));
                self.tabs[i].active_cmd = None;
                self.tabs[i].snap_result = None;
                self.tabs[i].scene.clear_preview_wire();
            }
            CmdResult::DdeditEntity { handle, new_text } => {
                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("DDEDIT: text updated.");
                } else {
                    self.discard_last_undo_entry(i);
                    self.command_line
                        .push_error("DDEDIT: entity type not supported.");
                }
                self.tabs[i].active_cmd = None;
                self.tabs[i].snap_result = None;
                self.tabs[i].scene.clear_preview_wire();
                self.restore_pre_cmd_tangent();
            }
        }
        // Keep the command-line input focused at all times — every typed
        // character is meant to route there (the command processor reads
        // its keystroke stream from this widget). When no command is
        // running the ribbon tool button still has to visually deactivate.
        if self.tabs[i].active_cmd.is_none() {
            self.ribbon.deactivate_tool();
        }
        // 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_clipboard_deps(&mut self, i: usize) {
        use acadrust::TableEntry;
        if self.clipboard_deps.is_empty() {
            return;
        }
        let doc = &mut self.tabs[i].scene.document;
        for rec in &self.clipboard_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 &self.clipboard_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 &self.clipboard_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 &self.clipboard_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);
            }
        }
    }

    /// 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<crate::command::EntityTransform>,
    ) -> Vec<Handle> {
        self.merge_clipboard_deps(i);
        self.merge_clipboard_blocks(i);
        let by_index: Vec<Handle> = 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();
        self.merge_clipboard_ext_objects(i, &by_index);
        // 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 mut handle_map = rustc_hash::FxHashMap::default();
            for (src, &new) in self.clipboard.iter().zip(by_index.iter()) {
                if !new.is_null() {
                    handle_map.insert(src.common().handle, new);
                }
            }
            let groups = self.clipboard_deps.groups.clone();
            self.tabs[i].scene.recreate_groups(groups, &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]) {
        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);
                }
            }
        }
        // The wires were tessellated before the filters existed; force a rebuild
        // so the clip is applied to the freshly-pasted, now-filtered inserts.
        self.tabs[i].scene.bump_geometry();
    }

    /// 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<usize, Handle> {
        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<Handle>,
) -> Option<Handle> {
    use std::collections::HashMap;
    let mut remap: HashMap<Handle, Handle> = HashMap::new();
    if let Some(eh) = entity_handle {
        remap.insert(cap.src_entity_handle, eh);
    }
    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()
}

/// 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<acadrust::Handle, acadrust::Handle>,
) {
    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);
        }
        ObjectType::Group(g) => {
            g.handle = new_handle;
            g.owner = map(g.owner);
            for h in g.entities.iter_mut() {
                *h = map(*h);
            }
        }
        // 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 ───────────────────────────────────────────────────────────

/// Parse `base_val,h1;h2;...;hN,spacing` and adjust parallel dimension positions.
fn apply_dimspace(scene: &mut crate::scene::Scene, encoded: &str) {
    // Format: "<base_handle>,<h1>;<h2>;...;<hN>,<spacing>"
    let parts: Vec<&str> = encoded.splitn(3, ',').collect();
    if parts.len() < 3 {
        return;
    }
    let base_val: u64 = parts[0].parse().unwrap_or(0);
    let other_vals: Vec<u64> = parts[1]
        .split(';')
        .filter_map(|s| s.parse::<u64>().ok())
        .collect();
    let spacing: f64 = parts[2].parse().unwrap_or(0.0);

    use acadrust::entities::Dimension;
    let base_h = acadrust::Handle::from(base_val);
    // Base dim: the perpendicular direction (from its rotation / axis) and the
    // dim line's perpendicular coordinate. Spacing steps each parallel dim along
    // this perp IN THE DRAWING PLANE — offsetting Z had no effect on the dim
    // line, which is computed from def·perp with perp.z = 0. (#181 / DIM-021)
    let (perp, base_coord) = match scene.document.get_entity(base_h) {
        Some(acadrust::EntityType::Dimension(Dimension::Linear(d))) => {
            let (s, c) = d.rotation.sin_cos();
            let perp = (-s, c);
            let dp = d.definition_point;
            (perp, dp.x * perp.0 + dp.y * perp.1)
        }
        Some(acadrust::EntityType::Dimension(Dimension::Aligned(d))) => {
            let dx = d.second_point.x - d.first_point.x;
            let dy = d.second_point.y - d.first_point.y;
            let len = (dx * dx + dy * dy).sqrt().max(1e-12);
            let perp = (-dy / len, dx / len);
            let dp = d.definition_point;
            (perp, dp.x * perp.0 + dp.y * perp.1)
        }
        _ => return,
    };

    let effective_spacing = if spacing <= 0.0 { 10.0 } else { spacing };
    for (idx, &hv) in other_vals.iter().enumerate() {
        let h = acadrust::Handle::from(hv);
        let target = base_coord + effective_spacing * (idx + 1) as f64;
        if let Some(acadrust::EntityType::Dimension(dim)) = scene.document.get_entity_mut(h) {
            // Slide this dim's definition point along perp so its perpendicular
            // coordinate equals `target`; update both the struct field (render)
            // and base (save).
            let slide = |p: &mut acadrust::types::Vector3| {
                let cur = p.x * perp.0 + p.y * perp.1;
                let delta = target - cur;
                p.x += perp.0 * delta;
                p.y += perp.1 * delta;
            };
            match dim {
                Dimension::Linear(d) => {
                    slide(&mut d.definition_point);
                    d.base.definition_point = d.definition_point;
                }
                Dimension::Aligned(d) => {
                    slide(&mut d.definition_point);
                    d.base.definition_point = d.definition_point;
                }
                _ => {}
            }
        }
        // The dimension line moved, so its baked *D block is stale — drop it so
        // the next save re-bakes it (no-op for non-dimensions). (#181)
        scene.invalidate_dim_block_recorded(h);
    }
    scene.bump_geometry();
}

// ── MLEADERALIGN helper ───────────────────────────────────────────────────────

/// Parse `h1,h2,...;fx,fz;tx,tz` and align multileader content points along the direction.
fn apply_mleader_align(scene: &mut crate::scene::Scene, encoded: &str) {
    // Format: "<h1>,<h2>,...;<fx>,<fz>;<tx>,<tz>"
    let parts: Vec<&str> = encoded.splitn(3, ';').collect();
    if parts.len() < 3 {
        return;
    }
    let handles: Vec<acadrust::Handle> = parts[0]
        .split(',')
        .filter_map(|s| s.parse::<u64>().ok().map(acadrust::Handle::from))
        .collect();
    let from_parts: Vec<f64> = parts[1].split(',').filter_map(|s| s.parse().ok()).collect();
    let to_parts: Vec<f64> = parts[2].split(',').filter_map(|s| s.parse().ok()).collect();
    if from_parts.len() < 2 || to_parts.len() < 2 || handles.is_empty() {
        return;
    }

    let fx = from_parts[0];
    let fz = from_parts[1];
    let tx = to_parts[0];
    let tz = to_parts[1];
    let dx = tx - fx;
    let dz = tz - fz;
    let len = (dx * dx + dz * dz).sqrt();
    if len < 1e-9 {
        return;
    }

    // Project each multileader's content point onto the alignment line, then
    // snap it to the line (preserve perpendicular offset from line is discarded;
    // align along direction through `from`).
    for h in handles {
        if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity_mut(h) {
            let cp = &mut ml.context.content_base_point;
            // Project onto line from_pt + t * dir: keep t component, set perpendicular = 0
            let rel_x = cp.x - fx;
            let rel_z = cp.z - fz;
            let t = (rel_x * (dx / len) + rel_z * (dz / len)) / len;
            let t = t.clamp(0.0, 1.0);
            cp.x = fx + t * dx;
            cp.z = fz + t * dz;
        }
    }
}

// ── MLEADERCOLLECT helper ─────────────────────────────────────────────────────

/// Parse `h1,h2,...;px,pz` — merge all selected multileaders into the first one at position.
fn apply_mleader_collect(scene: &mut crate::scene::Scene, encoded: &str) {
    let parts: Vec<&str> = encoded.splitn(2, ';').collect();
    if parts.len() < 2 {
        return;
    }
    let handles: Vec<acadrust::Handle> = parts[0]
        .split(',')
        .filter_map(|s| s.parse::<u64>().ok().map(acadrust::Handle::from))
        .collect();
    let pos_parts: Vec<f64> = parts[1].split(',').filter_map(|s| s.parse().ok()).collect();
    if handles.len() < 2 || pos_parts.len() < 2 {
        return;
    }

    let px = pos_parts[0];
    let pz = pos_parts[1];

    // Collect all leader roots from the secondary multileaders.
    let mut extra_roots: Vec<acadrust::entities::LeaderRoot> = Vec::new();
    for &h in &handles[1..] {
        if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity(h) {
            extra_roots.extend(ml.context.leader_roots.iter().cloned());
        }
    }

    // Add collected roots to the first multileader and move its content point.
    if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity_mut(handles[0]) {
        ml.context.content_base_point.x = px;
        ml.context.content_base_point.z = pz;
        for root in extra_roots {
            ml.context.leader_roots.push(root);
        }
    }

    // Erase the secondary multileaders.
    scene.erase_entities(&handles[1..]);
}

/// 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, t.width_factor, t.oblique_angle)),
        E::MText(m) => Some((m.style.clone(), m.height, 1.0, 0.0)),
        _ => None,
    };
    if let Some((style, height, wf, ob)) = text_fmt {
        match dst {
            E::Text(t) => {
                t.style = style;
                t.height = height;
                if matches!(src, E::Text(_)) {
                    t.width_factor = wf;
                    t.oblique_angle = ob;
                }
            }
            E::MText(m) => {
                m.style = style;
                m.height = height;
            }
            _ => {}
        }
    }
    // 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.gradient_color = sh.gradient_color.clone();
    }

    // Polyline display style: width + linetype generation.
    if let (E::LwPolyline(sp), E::LwPolyline(dp)) = (src, dst as &mut E) {
        dp.constant_width = sp.constant_width;
        dp.plinegen = sp.plinegen;
        for v in &mut dp.vertices {
            v.start_width = sp.constant_width;
            v.end_width = sp.constant_width;
        }
    }

    // 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.scale_factor = sm.scale_factor;
        dm.property_override_flags = sm.property_override_flags;
    }
}
