use super::*; impl OpenCADStudio { pub(crate) fn dispatch_view(&mut self, cmd: &str, i: usize) -> Option> { match cmd { "DONATE" => { self.command_line.push_info(crate::t!("Opening Patreon page...").as_ref()); return Some(crate::sys::open_url( "https://patreon.com/HakanSeven12", self.main_window, )); } "WEBVERSION" => { self.command_line.push_info(crate::t!("Opening OCS Web...").as_ref()); return Some(crate::sys::open_url( "https://hakanseven12.github.io/OpenCADStudio/", self.main_window, )); } "HELP" => { self.command_line .push_info(crate::t!("Opening OCS Discussions for help and questions...").as_ref()); return Some(crate::sys::open_url( "https://github.com/HakanSeven12/OpenCADStudio/discussions", self.main_window, )); } // ── DWGPROPS — print round-trip-only HeaderVariables ───────── // No UI dialog for these yet; the command surfaces them so // users can confirm the values that the parser populated and // the writer will round-trip on save. "DWGPROPS" | "DWGPROP" => { let i = self.active_tab; let h = &self.tabs[i].scene.document.header; let path_label = self.tabs[i] .current_path .as_ref() .map(|p| p.display().to_string()) .unwrap_or_else(|| "(unsaved)".to_string()); self.command_line .push_output(crate::tf!("Drawing: {}", path_label).as_ref()); self.command_line .push_output(crate::tf!(" Created (Julian): {:.6}", h.create_date_julian).as_ref()); self.command_line .push_output(crate::tf!(" Updated (Julian): {:.6}", h.update_date_julian).as_ref()); self.command_line .push_output(crate::tf!(" Total edit time: {:.4}", h.total_editing_time).as_ref()); self.command_line .push_output(crate::tf!(" User elapsed: {:.4}", h.user_elapsed_time).as_ref()); self.command_line.push_output(crate::tf!( " Last saved by: {}", if h.last_saved_by.is_empty() { "(unknown)" } else { &h.last_saved_by } ).as_ref()); self.command_line.push_output(crate::tf!( " Fingerprint GUID: {}", if h.fingerprint_guid.is_empty() { "(none)" } else { &h.fingerprint_guid } ).as_ref()); self.command_line.push_output(crate::tf!( " Version GUID: {}", if h.version_guid.is_empty() { "(none)" } else { &h.version_guid } ).as_ref()); self.command_line .push_output(crate::tf!(" Code page: {}", h.code_page).as_ref()); self.command_line.push_output(crate::tf!( " Menu name: {}", if h.menu_name.is_empty() { "(none)" } else { &h.menu_name } ).as_ref()); self.command_line.push_output(crate::tf!( " Hyperlink base: {}", if h.hyperlink_base.is_empty() { "(none)" } else { &h.hyperlink_base } ).as_ref()); self.command_line.push_output(crate::tf!( " Project name: {}", if h.project_name.is_empty() { "(none)" } else { &h.project_name } ).as_ref()); self.command_line.push_output(crate::tf!( " Stylesheet: {}", if h.stylesheet.is_empty() { "(none)" } else { &h.stylesheet } ).as_ref()); self.command_line.push_output(crate::tf!( " Required versions: {:#018x}", h.required_versions ).as_ref()); self.command_line.push_output(crate::tf!( " Measurement: {} ({})", h.measurement, if h.measurement == 1 { "Metric" } else { "Imperial" } ).as_ref()); self.command_line .push_output(crate::tf!(" Proxy graphics: {}", h.proxy_graphics).as_ref()); self.command_line .push_output(crate::tf!(" Tree depth: {}", h.tree_depth).as_ref()); self.command_line.push_output(crate::tf!( " User vars (int): {} {} {} {} {}", h.user_int1, h.user_int2, h.user_int3, h.user_int4, h.user_int5 ).as_ref()); self.command_line.push_output(crate::tf!( " User vars (real): {:.6} {:.6} {:.6} {:.6} {:.6}", h.user_real1, h.user_real2, h.user_real3, h.user_real4, h.user_real5 ).as_ref()); self.command_line.push_output(crate::tf!( " User timer: {}", if h.user_timer { "On" } else { "Off" } ).as_ref()); } // Edit a USERI1..USERI5 / USERR1..USERR5 slot. Lets the user // store drawing-scoped scalars (and save them through round-trip) // even though we don't have a LISP / DIESEL runtime yet. // USERI 1 42 → header.user_int1 = 42 // USERR 3 1.5e-3 → header.user_real3 = 0.0015 "USERI" | "USERR" => { use crate::command::UserRegCommand; let name = if cmd == "USERR" { "USERR" } else { "USERI" }; let c = UserRegCommand::new(name); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } cmd if cmd.starts_with("USERI ") || cmd.starts_with("USERR ") => { let is_real = cmd.starts_with("USERR"); let rest = if is_real { cmd.trim_start_matches("USERR").trim() } else { cmd.trim_start_matches("USERI").trim() }; let parts: Vec<&str> = rest.splitn(2, ' ').collect(); let slot: Option = parts.first().and_then(|s| s.parse().ok()); let value = parts.get(1).copied().unwrap_or("").trim(); let i = self.active_tab; let h = &mut self.tabs[i].scene.document.header; match (slot, value, is_real) { (Some(n @ 1..=5), v, true) => { if let Ok(val) = v.parse::() { match n { 1 => h.user_real1 = val, 2 => h.user_real2 = val, 3 => h.user_real3 = val, 4 => h.user_real4 = val, _ => h.user_real5 = val, } self.tabs[i].dirty = true; self.command_line.push_output(crate::tf!("USERR{n} = {val}").as_ref()); } else { self.command_line.push_info(crate::t!("Usage: USERR <1-5> ").as_ref()); } } (Some(n @ 1..=5), v, false) => { if let Ok(val) = v.parse::() { match n { 1 => h.user_int1 = val, 2 => h.user_int2 = val, 3 => h.user_int3 = val, 4 => h.user_int4 = val, _ => h.user_int5 = val, } self.tabs[i].dirty = true; self.command_line.push_output(crate::tf!("USERI{n} = {val}").as_ref()); } else { self.command_line.push_info(crate::t!("Usage: USERI <1-5> ").as_ref()); } } _ => self .command_line .push_info(crate::t!("Usage: USERI <1-5> | USERR <1-5> ").as_ref()), } } "REPORT" => { // Pre-fill the GitHub issue body with version + platform so // reports arrive with the basics already filled in. let body = format!( "\n\n\n\ ---\n- Open CAD Studio: v{}\n- Platform: {}\n", env!("CARGO_PKG_VERSION"), crate::sys::platform_info(), ); let url = format!( "https://github.com/HakanSeven12/OpenCADStudio/issues/new?body={}", crate::sys::percent_encode(&body) ); self.command_line.push_info(crate::t!("Opening feedback page...").as_ref()); return Some(crate::sys::open_url(&url, self.main_window)); } "ABOUT" => { return Some(Task::done(Message::AboutOpen)); } "PLUGINS" | "PLUGINMANAGER" => { return Some(Task::done(Message::PluginManagerOpen)); } "CHANGELOG" => { self.command_line.push_info(crate::t!("Opening release notes...").as_ref()); return Some(crate::sys::open_url( "https://github.com/HakanSeven12/OpenCADStudio/releases", self.main_window, )); } // ── ALIASEDIT — command-alias editor ─────────────────────────── // Opens the command-alias table editor (ocad.pgp). Keyboard // key-bindings are edited separately via CUI (below). "ALIASEDIT" => { return Some(Task::done(Message::AliasEditorOpen)); } // ── CUI — keyboard shortcut / key-binding editor ─────────────── "CUI" => { return Some(Task::done(Message::ShortcutsPanelOpen)); } // CUIEXPORT — write the keyboard-shortcut customizations // (the drawing-independent CUI data) to a plain "KEY COMMAND" file. "CUIEXPORT" => { use crate::command::ValuePromptCommand; let c = ValuePromptCommand::new("CUIEXPORT", "CUIEXPORT file to save shortcuts to:"); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } cmd if cmd.starts_with("CUIEXPORT ") => { let path = cmd.trim_start_matches("CUIEXPORT").trim(); if path.is_empty() { self.command_line.push_info( "Usage: CUIEXPORT — save the keyboard shortcuts to a file.", ); return Some(Task::none()); } let mut keys: Vec<(&String, &String)> = self.shortcut_bindings.iter().collect(); keys.sort_by(|a, b| a.0.cmp(b.0)); let text: String = keys.iter().map(|(k, v)| format!("{k} {v}\n")).collect(); let count = self.shortcut_bindings.len(); match std::fs::write(path, text) { Ok(()) => self.command_line.push_output(crate::tf!( "CUIEXPORT: wrote {count} shortcut(s) to \"{path}\"." ).as_ref()), Err(e) => self .command_line .push_error(crate::tf!("CUIEXPORT: cannot write \"{path}\": {e}").as_ref()), } } // CUIIMPORT / CUILOAD — load shortcut customizations from a // "KEY COMMAND" file (lines starting with # are ignored). "CUIIMPORT" | "CUILOAD" => { use crate::command::ValuePromptCommand; let c = ValuePromptCommand::new("CUIIMPORT", "CUIIMPORT shortcuts file to load:"); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } cmd if cmd.starts_with("CUIIMPORT ") || cmd.starts_with("CUILOAD ") => { let path = cmd .trim_start_matches("CUIIMPORT") .trim_start_matches("CUILOAD") .trim(); if path.is_empty() { self.command_line.push_info( "Usage: CUIIMPORT — load keyboard shortcuts from a file.", ); return Some(Task::none()); } match std::fs::read_to_string(path) { Ok(text) => { let mut n = 0usize; for line in text.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } if let Some((k, v)) = line.split_once(char::is_whitespace) { let key = crate::app::shortcuts::normalize_key(k); if !key.is_empty() { self.shortcut_bindings .insert(key, v.trim().to_uppercase()); n += 1; } } } self.persist_settings_if_changed(); self.command_line.push_output(crate::tf!( "CUIIMPORT: loaded {n} shortcut(s) from \"{path}\"." ).as_ref()); } Err(e) => self .command_line .push_error(crate::tf!("CUIIMPORT: cannot read \"{path}\": {e}").as_ref()), } } // ── Keyboard Shortcuts panel ────────────────────────────────── cmd if cmd == "SHORTCUTS" || cmd.starts_with("SHORTCUTS ") => { let raw_rest = cmd.trim_start_matches("SHORTCUTS").trim(); let parts: Vec<&str> = raw_rest.splitn(3, ' ').collect(); let sub = parts.first().map(|s| s.to_uppercase()).unwrap_or_default(); match sub.as_str() { "" | "LIST" | "?" => { return Some(Task::done(Message::ShortcutsPanelOpen)); } "SET" | "S" => { // SHORTCUTS SET // e.g. SHORTCUTS SET CTRL+D DIST let key = parts.get(1).map(|s| s.to_uppercase()).unwrap_or_default(); let cmd_str = parts.get(2).map(|s| s.to_uppercase()).unwrap_or_default(); if key.is_empty() || cmd_str.is_empty() { self.command_line.push_error(crate::t!("Usage: SHORTCUTS SET e.g. SHORTCUTS SET CTRL+D DIST").as_ref()); } else { let key = crate::app::shortcuts::normalize_key(&key); if key.is_empty() { self.command_line.push_error(crate::t!("Usage: SHORTCUTS SET e.g. SHORTCUTS SET CTRL+D DIST").as_ref()); } else { self.shortcut_bindings.insert(key.clone(), cmd_str.clone()); self.persist_settings_if_changed(); self.command_line .push_output(crate::tf!("Shortcut set: {key} → {cmd_str}").as_ref()); } } } "CLEAR" | "DELETE" | "REMOVE" => { let key = parts.get(1).map(|s| s.to_uppercase()).unwrap_or_default(); if key.is_empty() { self.command_line.push_error(crate::t!("Usage: SHORTCUTS CLEAR ").as_ref()); } else if self .shortcut_bindings .remove(&crate::app::shortcuts::normalize_key(&key)) .is_some() { self.persist_settings_if_changed(); self.command_line .push_output(crate::tf!("Shortcut '{key}' removed.").as_ref()); } else { self.command_line .push_error(crate::tf!("Shortcut '{key}' not found.").as_ref()); } } _ => { self.command_line .push_info(crate::t!("Usage: SHORTCUTS LIST | SET | CLEAR ").as_ref()); } } } // ── Color Scheme / Theme selector ───────────────────────────── cmd if cmd == "COLORSCHEME" || cmd.starts_with("COLORSCHEME ") => { use iced::Theme; let sub = cmd .split_once(' ') .map(|(_, r)| r.trim()) .unwrap_or("") .to_uppercase(); // Map name to Theme variant. let theme: Option = match sub.as_str() { "DARK" => Some(Theme::Dark), "LIGHT" => Some(Theme::Light), "DRACULA" => Some(Theme::Dracula), "NORD" => Some(Theme::Nord), "SOLARIZED_LIGHT" | "SOLARIZEDLIGHT" => Some(Theme::SolarizedLight), "SOLARIZED_DARK" | "SOLARIZEDDARK" => Some(Theme::SolarizedDark), "GRUVBOX_LIGHT" | "GRUVBOXLIGHT" => Some(Theme::GruvboxLight), "GRUVBOX_DARK" | "GRUVBOXDARK" => Some(Theme::GruvboxDark), "TOKYONIGHT" | "TOKYO_NIGHT" => Some(Theme::TokyoNight), "TOKYONIGHTSTORM" | "TOKYO_NIGHT_STORM" => Some(Theme::TokyoNightStorm), "TOKYONIGHTLIGHT" | "TOKYO_NIGHT_LIGHT" => Some(Theme::TokyoNightLight), "KANAGAWAWAVE" | "KANAGAWA_WAVE" => Some(Theme::KanagawaWave), "KANAGAWADRAGON" | "KANAGAWA_DRAGON" => Some(Theme::KanagawaDragon), "KANAGAWALOTUS" | "KANAGAWA_LOTUS" => Some(Theme::KanagawaLotus), "MOONFLY" => Some(Theme::Moonfly), "NIGHTFLY" => Some(Theme::Nightfly), "OXOCARBON" => Some(Theme::Oxocarbon), "FERRA" => Some(Theme::Ferra), "" | "LIST" | "?" => { self.command_line.push_output( "Available themes: DARK LIGHT DRACULA NORD SOLARIZED_LIGHT SOLARIZED_DARK \ GRUVBOX_LIGHT GRUVBOX_DARK TOKYONIGHT TOKYONIGHTSTORM TOKYONIGHTLIGHT \ KANAGAWAWAVE KANAGAWADRAGON KANAGAWALOTUS MOONFLY NIGHTFLY OXOCARBON FERRA" ); return Some(Task::none()); } _ => { self.command_line.push_error(crate::tf!( "COLORSCHEME: unknown theme '{}'. Type COLORSCHEME LIST for options.", sub ).as_ref()); return Some(Task::none()); } }; if let Some(t) = theme { let name = format!("{:?}", t); self.command_line .push_output(crate::tf!("Color scheme set to '{name}'.").as_ref()); return Some(Task::done(Message::SetTheme(t))); } return Some(Task::none()); } // ── Layout Manager GUI ───────────────────────────────────────── "LAYOUTMANAGER" | "LAYOUTPANEL" => { return Some(Task::done(Message::LayoutManagerOpen)); } // ── Layout / viewport ────────────────────────────────────────── "MVIEW" => { if self.tabs[i].scene.current_layout == "Model" { self.command_line .push_error(crate::t!("MVIEW: switch to a paper space layout first.").as_ref()); } else { use crate::modules::layout::mview::MviewCommand; let scene = &self.tabs[i].scene; let layout = scene.current_layout.clone(); let paper_bounds = scene .printable_area_limits() .or_else(|| scene.paper_limits()) .unwrap_or(((0.0, 0.0), (297.0, 210.0))); let views = scene.document.views.iter().cloned().collect(); let new_cmd = MviewCommand::new(layout, paper_bounds, views); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } } // ── MSPACE / PSPACE ─────────────────────────────────────────── "MSPACE" => { return Some(Task::done(Message::MspaceCommand)); } "PSPACE" => { return Some(Task::done(Message::PspaceCommand)); } // ── Viewport arrangement shortcuts ──────────────────────────── // Tile the model viewports into preset splits. Each delegates to the // matching VPORTS configuration so the Model/paper handling stays in // one place. "HORIZONTAL" => return self.dispatch_view("VPORTS 2H", i), "VERTICAL" => return self.dispatch_view("VPORTS 2V", i), "VPJOIN" => return self.dispatch_view("VPORTS SINGLE", i), "CASCADE" => return self.dispatch_view("VPORTS 4", i), // ── VPORTS — list or create preset viewport configurations ──── cmd if cmd == "VPORTS" || cmd.starts_with("VPORTS ") => { let sub = cmd.split_whitespace().nth(1).unwrap_or("").to_uppercase(); let scene = &self.tabs[i].scene; if scene.current_layout == "Model" { // Bare VPORTS → ask for the configuration interactively; // the next command-line entry supplies it. if sub.is_empty() { self.awaiting_vports = true; self.command_line .push_info(crate::t!("VPORTS Configuration [SIngle/2H/2V/4]:").as_ref()); return Some(self.focus_cmd_input()); } // Model space: split the tiled viewport layout via pane_grid. use iced::widget::pane_grid::{Axis, Configuration as C}; let split = |axis, a, b| C::Split { axis, ratio: 0.5, a: Box::new(a), b: Box::new(b), }; let config: Option<(C, usize)> = match sub.as_str() { "SINGLE" | "SI" | "1" => Some((C::Pane(0), 1)), "2H" | "2" => Some((split(Axis::Horizontal, C::Pane(0), C::Pane(1)), 2)), "2V" => Some((split(Axis::Vertical, C::Pane(0), C::Pane(1)), 2)), "4" => Some(( split( Axis::Vertical, split(Axis::Horizontal, C::Pane(0), C::Pane(2)), split(Axis::Horizontal, C::Pane(1), C::Pane(3)), ), 4, )), _ => None, }; match config { Some((config, n)) => { self.tabs[i].scene.set_model_panes(config); self.tabs[i].scene.camera_generation += 1; self.command_line .push_output(crate::tf!("VPORTS: {n} viewport(s).").as_ref()); } None => { self.command_line .push_error(crate::t!("VPORTS: use SINGLE | 2H | 2V | 4.").as_ref()); } } } else if sub.is_empty() { // ── List existing viewports ────────────────────────── let layout_block = scene.current_layout_block_handle_pub(); let viewports: Vec<_> = scene .document .entities() .filter_map(|e| { if let acadrust::EntityType::Viewport(vp) = e { if vp.id > 1 && vp.common.owner_handle == layout_block { Some(( vp.id, vp.center.clone(), vp.width, vp.height, crate::scene::vp_effective_scale( vp.custom_scale, vp.view_height, vp.height, ), vp.status.is_on, vp.status.locked, )) } else { None } } else { None } }) .collect(); if viewports.is_empty() { self.command_line.push_info(crate::t!("No viewports. Use MVIEW to create one, or VPORTS 2H / 2V / 4 / SINGLE.").as_ref()); } else { self.command_line.push_output(crate::tf!( "{} viewport(s) in layout \"{}\":", viewports.len(), scene.current_layout ).as_ref()); for (id, center, w, h, scale, is_on, locked) in &viewports { let state = match (is_on, locked) { (true, true) => "On, Locked", (true, false) => "On", (false, _) => "Off", }; self.command_line.push_output(crate::tf!( " VP #{id}: {w:.1}×{h:.1} @ ({:.1},{:.1}) scale={scale:.4} [{state}]", center.x, center.y ).as_ref()); } } } else { // ── Preset viewport layout ─────────────────────────── let layout_name = scene.current_layout.clone(); let ((x0, y0), (x1, y1)) = scene .printable_area_limits() .unwrap_or(((0.0, 0.0), (297.0, 210.0))); let uw = (x1 - x0).max(1.0); let uh = (y1 - y0).max(1.0); let gap = 2.0 * scene.paper_space_unit_factor(); let rects: Vec<(f64, f64, f64, f64)> = match sub.as_str() { "2H" => { // Two viewports side by side (horizontal split) let vw = (uw - gap) / 2.0; vec![ (x0 + vw / 2.0, y0 + uh / 2.0, vw, uh), (x0 + vw + gap + vw / 2.0, y0 + uh / 2.0, vw, uh), ] } "2V" => { // Two viewports stacked (vertical split) let vh = (uh - gap) / 2.0; vec![ (x0 + uw / 2.0, y0 + vh + gap + vh / 2.0, uw, vh), (x0 + uw / 2.0, y0 + vh / 2.0, uw, vh), ] } "4" => { // Four equal viewports (2×2 grid) let vw = (uw - gap) / 2.0; let vh = (uh - gap) / 2.0; vec![ (x0 + vw / 2.0, y0 + vh + gap + vh / 2.0, vw, vh), ( x0 + vw + gap + vw / 2.0, y0 + vh + gap + vh / 2.0, vw, vh, ), (x0 + vw / 2.0, y0 + vh / 2.0, vw, vh), (x0 + vw + gap + vw / 2.0, y0 + vh / 2.0, vw, vh), ] } "SINGLE" | "1" => { // Single full-page viewport vec![(x0 + uw / 2.0, y0 + uh / 2.0, uw, uh)] } _ => { self.command_line.push_error( "VPORTS: unknown option. Use VPORTS 2H | 2V | 4 | SINGLE", ); vec![] } }; if !rects.is_empty() { // Remove existing user viewports in this layout first. let layout_block = self.tabs[i].scene.current_layout_block_handle_pub(); let to_erase: Vec = self.tabs[i] .scene .document .entities() .filter_map(|e| { if let acadrust::EntityType::Viewport(vp) = e { if vp.id > 1 && vp.common.owner_handle == layout_block { Some(vp.common.handle) } else { None } } else { None } }) .collect(); if to_erase .iter() .any(|handle| self.tabs[i].scene.is_layer_locked(*handle)) { self.command_line.push_error( crate::t!("VPORTS: unlock existing viewport layers first.") .as_ref(), ); return Some(Task::none()); } self.push_undo_snapshot(i, "VPORTS"); self.tabs[i].scene.erase_entities(&to_erase); // Create new viewports. for (cx, cz, w, h) in &rects { let mut vp = acadrust::entities::Viewport::new(); vp.center = acadrust::types::Vector3::new(*cx, 0.0, *cz); vp.width = *w; vp.height = *h; vp.id = 2; // commit_entity will assign unique IDs match self.tabs[i].scene.document.add_entity_to_layout( acadrust::EntityType::Viewport(vp), &layout_name, ) { Ok(handle) => { self.tabs[i].scene.auto_fit_viewport(handle); } Err(e) => { self.command_line.push_error(crate::tf!("VPORTS: {e}").as_ref()); } } } // Re-assign unique IDs (1 + existing max per viewport). let layout_block2 = self.tabs[i].scene.current_layout_block_handle_pub(); let mut id_counter = 2_i16; let handles: Vec = self.tabs[i] .scene .document .entities() .filter_map(|e| { if let acadrust::EntityType::Viewport(vp) = e { if vp.id >= 2 && vp.common.owner_handle == layout_block2 { Some(vp.common.handle) } else { None } } else { None } }) .collect(); for h in handles { if let Some(acadrust::EntityType::Viewport(vp)) = self.tabs[i].scene.document.get_entity_mut(h) { vp.id = id_counter; id_counter += 1; } } self.tabs[i].dirty = true; self.command_line.push_output(crate::tf!( "VPORTS: created {} viewport(s) [{}].", rects.len(), sub ).as_ref()); } } } // ── VPLAYER — per-viewport layer freeze/thaw ────────────────── "VPLAYER" => { let scene = &self.tabs[i].scene; if scene.current_layout == "Model" { self.command_line .push_error(crate::t!("VPLAYER: switch to a paper space layout first.").as_ref()); } else if scene.active_viewport.is_none() { self.command_line .push_error(crate::t!("VPLAYER: enter a viewport first (double-click or MS).").as_ref()); } else { use crate::modules::layout::vplayer::VplayerCommand; let vp_handle = scene.active_viewport.unwrap(); // Collect current frozen layer names for display. let frozen_names: Vec = { if let Some(acadrust::EntityType::Viewport(vp)) = scene.document.get_entity(vp_handle) { vp.frozen_layers .iter() .filter_map(|h| { scene .document .layers .iter() .find(|l| l.handle == *h) .map(|l| l.name.clone()) }) .collect() } else { vec![] } }; if frozen_names.is_empty() { self.command_line .push_info(crate::t!("VPLAYER: no frozen layers in active viewport.").as_ref()); } else { self.command_line.push_info(crate::tf!( "VPLAYER: frozen layers: {}", frozen_names.join(", ") ).as_ref()); } let new_cmd = VplayerCommand::new(vp_handle); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } } // ── Draw Order ──────────────────────────────────────────────── // TEXTTOFRONT / TEXTTOBACK — move every text and dimension object to // the front (or back) of the draw order via the DRAWORDER machinery. "TEXTTOFRONT" | "TEXTTOBACK" => { let to_front = cmd.ends_with("FRONT"); let block_handle = self.tabs[i].scene.current_layout_block_handle_pub(); let handles: rustc_hash::FxHashSet = self.tabs[i] .scene .document .entities() .filter(|e| { let c = e.common(); (c.owner_handle == block_handle || c.owner_handle.is_null()) && matches!( e, acadrust::EntityType::Text(_) | acadrust::EntityType::MText(_) | acadrust::EntityType::Dimension(_) ) }) .map(|e| e.common().handle) .collect(); if handles.is_empty() { self.command_line .push_info(crate::tf!("{cmd}: no text or dimension objects.").as_ref()); return Some(Task::none()); } self.tabs[i].scene.replace_selection(handles); return self.dispatch_view( if to_front { "DRAWORDER FRONT" } else { "DRAWORDER BACK" }, i, ); } // HATCHTOBACK ÔÇö move every hatch object in the active space to the back of the draw order. "HATCHTOBACK" => { use acadrust::objects::{ObjectType, SortEntitiesTable}; let block_handle = self.tabs[i].scene.current_layout_block_handle_pub(); let doc_ref = &self.tabs[i].scene.document; // 1. Single scan over objects to find existing SortEntitiesTable handle & overrides. let mut existing_table_handle = None; let mut overrides: Option> = None; for (h, obj) in &doc_ref.objects { if let ObjectType::SortEntitiesTable(t) = obj { if t.block_owner_handle == block_handle { existing_table_handle = Some(*h); if !t.is_empty() { overrides = Some( t.entries() .map(|e| (e.entity_handle.value(), e.sort_handle.value())) .collect(), ); } break; } } } // 2. Pre-filter locked layers with zero string allocation. let locked_layers: rustc_hash::FxHashSet<&str> = doc_ref .layers .iter() .filter(|l| l.is_locked()) .map(|l| l.name.as_str()) .collect(); let has_locked_layers = !locked_layers.is_empty(); // 3. Single pass over entities in the active space. let mut hatches_to_move: Vec = Vec::new(); let mut min_eff = u64::MAX; for e in doc_ref.entities() { let c = e.common(); if c.owner_handle != block_handle && !c.owner_handle.is_null() { continue; } let hv = c.handle.value(); if matches!(e, acadrust::EntityType::Hatch(_)) { if !has_locked_layers || !locked_layers.contains(c.layer.as_str()) { hatches_to_move.push(c.handle); } } else { let eff = match &overrides { Some(map) => map.get(&hv).copied().unwrap_or(hv), None => hv, }; min_eff = min_eff.min(eff); } } if hatches_to_move.is_empty() { self.command_line .push_info(crate::tf!("{cmd}: no hatch objects.").as_ref()); return Some(Task::none()); } if min_eff == u64::MAX { min_eff = 1; } // Compute all sort-key assignments before any mutable step: // the exhausted-floor path needs one more read-only pass over // entities to find colliding siblings to lift. let assigns = assign_back_group_keys( doc_ref, block_handle, &hatches_to_move, min_eff, overrides.as_ref(), &locked_layers, ); // 4. Ultra-fast targeted Delta Undo (snapshots ONLY the SortEntitiesTable, zero full-drawing clone). let pending_delta = self.begin_undo(i, "DRAWORDER", hatches_to_move.len(), true); // 5. Update or insert SortEntitiesTable directly. let table_before = existing_table_handle .and_then(|h| self.tabs[i].scene.document.objects.get(&h).cloned()); let th = existing_table_handle.unwrap_or_else(|| { let nh = acadrust::Handle::new(self.tabs[i].scene.document.next_handle()); let mut table = SortEntitiesTable::for_block(block_handle); table.handle = nh; self.tabs[i] .scene .document .objects .insert(nh, ObjectType::SortEntitiesTable(table)); nh }); self.tabs[i].scene.record_undo_object_before(th, table_before); if let Some(ObjectType::SortEntitiesTable(table)) = self.tabs[i].scene.document.objects.get_mut(&th) { for (h, sort) in &assigns { table.add_entry(*h, acadrust::Handle::new(*sort)); } } if let Some(pending) = pending_delta { self.commit_undo_delta(i, pending); } // 6. Invalidate ONLY draw-depth cache without dropping whole-drawing tessellations or spatial indexes. let changes: Vec<(acadrust::Handle, crate::scene::ChangeKind)> = hatches_to_move .iter() .map(|h| (*h, crate::scene::ChangeKind::Modified)) .collect(); self.tabs[i].scene.bump_entities(&changes); self.tabs[i].scene.invalidate_draw_depth(); self.tabs[i].dirty = true; self.command_line.push_info( crate::tf!("DRAWORDER: moved {} entities to back.", hatches_to_move.len()).as_ref(), ); return Some(Task::none()); } "DRAWORDER" => { let selected: Vec = self.tabs[i] .scene .selected_entities() .iter() .map(|(h, _)| *h) .collect(); let c = DrawOrderCommand::new(selected); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } cmd if cmd.starts_with("DRAWORDER ") => { use acadrust::objects::{ObjectType, SortEntitiesTable}; let parts: Vec<&str> = cmd.split_whitespace().collect(); let option = parts.get(1).unwrap_or(&"").to_uppercase(); let i = self.active_tab; let selected: Vec = self.tabs[i] .scene .selected_entities() .iter() .map(|(h, _)| *h) .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if selected.is_empty() { self.command_line .push_error(crate::t!("DRAWORDER: select entities first.").as_ref()); } else { // Parse relative target handle for ABOVE/UNDER. let relative_target: Option<(bool, acadrust::Handle)> = match option.as_str() { "A" | "ABOVE" => { let h_val = parts.get(2).and_then(|s| u64::from_str_radix(s, 16).ok()); h_val.map(|v| (true, acadrust::Handle::new(v))) } "U" | "UNDER" | "BELOW" => { let h_val = parts.get(2).and_then(|s| u64::from_str_radix(s, 16).ok()); h_val.map(|v| (false, acadrust::Handle::new(v))) } _ => None, }; let to_front_opt = match option.as_str() { "F" | "FRONT" => Some(true), "B" | "BACK" => Some(false), _ => None, }; if relative_target.is_some() || to_front_opt.is_some() { self.push_undo_snapshot(i, "DRAWORDER"); let block_handle = self.tabs[i].scene.current_layout_block_handle_pub(); // For FRONT/BACK, anchor the new sort handle to the // block's current effective draw-order range so the moved // entities land strictly above/below every sibling — // including ones not yet in the table, which sort by // their own handle. (min_eff, max_eff) over siblings. let mut back_assigns: Option> = None; let fb_baseline: Option<(u64, u64)> = if to_front_opt.is_some() { let selected_set: rustc_hash::FxHashSet = selected.iter().map(|h| h.value()).collect(); let doc_ref = &self.tabs[i].scene.document; let overrides: rustc_hash::FxHashMap = doc_ref .objects .values() .find_map(|obj| { if let ObjectType::SortEntitiesTable(t) = obj { if t.block_owner_handle == block_handle { return Some( t.entries() .map(|e| { ( e.entity_handle.value(), e.sort_handle.value(), ) }) .collect(), ); } } None }) .unwrap_or_default(); let mut max_eff = 0u64; let mut min_eff = u64::MAX; for e in doc_ref.entities() { let c = e.common(); let hv = c.handle.value(); if selected_set.contains(&hv) { continue; } if c.owner_handle != block_handle && !c.owner_handle.is_null() { continue; } let eff = overrides.get(&hv).copied().unwrap_or(hv); max_eff = max_eff.max(eff); min_eff = min_eff.min(eff); } if min_eff == u64::MAX { min_eff = 1; } if to_front_opt == Some(false) { // Same exhausted-floor handling as HATCHTOBACK: // never clamp the group onto tied keys. let locked_layers: rustc_hash::FxHashSet<&str> = doc_ref .layers .iter() .filter(|l| l.is_locked()) .map(|l| l.name.as_str()) .collect(); back_assigns = Some(assign_back_group_keys( doc_ref, block_handle, &selected, min_eff, Some(&overrides), &locked_layers, )); } Some((min_eff, max_eff)) } else { None }; let doc = &mut self.tabs[i].scene.document; let table_handle = doc.objects.iter().find_map(|(h, obj)| { if let ObjectType::SortEntitiesTable(t) = obj { if t.block_owner_handle == block_handle { Some(*h) } else { None } } else { None } }); let th = table_handle.unwrap_or_else(|| { let nh = acadrust::Handle::new(doc.next_handle()); let mut table = SortEntitiesTable::for_block(block_handle); table.handle = nh; doc.objects.insert(nh, ObjectType::SortEntitiesTable(table)); nh }); if let Some(ObjectType::SortEntitiesTable(table)) = doc.objects.get_mut(&th) { if let Some((above, target)) = relative_target { // move_above/move_below recompute target±1 per // call, so looping them over N selected entities // ties the whole group onto one key. Read the // reference key once and hand out per-index // keys instead, keeping the moved entities a // distinct, selection-ordered block adjacent to // the reference. let target_sort = match table.get_sort_handle(target) { Some(h) => h.value(), None => { // A reference object that was never // reordered isn't in the table yet; // seed it with its own handle as the // implicit sort key. table.add_entry(target, target); table .get_sort_handle(target) .map_or(target.value(), |h| h.value()) } }; for (k, h) in selected.iter().enumerate() { let offset = 1 + k as u64; let sort = if above { target_sort.saturating_add(offset) } else { target_sort.saturating_sub(offset).max(1) }; table.add_entry(*h, acadrust::Handle::new(sort)); } let rel = if above { "above" } else { "below" }; self.command_line.push_info(crate::tf!( "DRAWORDER: moved {} entities {} {:x}.", selected.len(), rel, target.value() ).as_ref()); } else if let Some(to_front) = to_front_opt { if to_front { let (_, max_eff) = fb_baseline.unwrap_or((1, 0)); for (k, h) in selected.iter().enumerate() { let sort = max_eff.saturating_add(1 + k as u64); table.add_entry(*h, acadrust::Handle::new(sort)); } } else if let Some(assigns) = &back_assigns { for (h, sort) in assigns { table.add_entry(*h, acadrust::Handle::new(*sort)); } } let dir = if to_front { "front" } else { "back" }; self.command_line.push_info(crate::tf!( "DRAWORDER: moved {} entities to {}.", selected.len(), dir ).as_ref()); } } // Sort order lives in SortEntitiesTable, which the // render-side `sort_cache` rebuilds per geometry epoch. // Bump it so the new draw order shows immediately // instead of waiting for an unrelated geometry change. // Draw order changes submission order only; all // per-entity tessellation remains valid. self.tabs[i].scene.bump_geometry_no_blocks(); self.tabs[i].dirty = true; } else { self.command_line.push_info( crate::t!("Usage: DRAWORDER F|FRONT | B|BACK | A|ABOVE | U|UNDER ").as_ref() ); } } } // SYNCPVIEWPORTS — copy the first selected viewport's display settings // (view direction/target, scale, snap/grid, frozen layers) to the rest. "SYNCPVIEWPORTS" | "VPSYNC" => { let vps: Vec = self.tabs[i] .scene .selected_entities() .iter() .filter(|(_, e)| matches!(e, acadrust::EntityType::Viewport(_))) .map(|(h, _)| *h) .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if vps.len() < 2 { self.command_line.push_error( "SYNCPVIEWPORTS: select two or more viewports (the first is the master).", ); return Some(Task::none()); } let src = match self.tabs[i].scene.document.get_entity(vps[0]) { Some(acadrust::EntityType::Viewport(vp)) => vp.clone(), _ => { self.command_line .push_error(crate::t!("SYNCPVIEWPORTS: master is not a viewport.").as_ref()); return Some(Task::none()); } }; self.push_undo_snapshot(i, "SYNCPVIEWPORTS"); let mut n = 0usize; for h in &vps[1..] { if let Some(acadrust::EntityType::Viewport(vp)) = self.tabs[i].scene.document.get_entity_mut(*h) { vp.view_direction = src.view_direction; vp.view_target = src.view_target; vp.view_height = src.view_height; vp.snap_base = src.snap_base; vp.snap_spacing = src.snap_spacing; vp.snap_angle = src.snap_angle; vp.grid_spacing = src.grid_spacing; vp.grid_major = src.grid_major; vp.frozen_layers = src.frozen_layers.clone(); n += 1; } } self.tabs[i].dirty = true; self.command_line.push_output(crate::tf!( "SYNCPVIEWPORTS: synced {n} viewport(s) to the master." ).as_ref()); } // HIDE — hidden-line view of the active viewport. "HIDE" => { return Some(Task::done(Message::SetRenderMode( acadrust::entities::ViewportRenderMode::HiddenLine, ))); } // VISUALSTYLES — put the active viewport in one of the seven // render modes. The style-definition manager is not modelled. cmd if cmd.starts_with("VISUALSTYLES ") => { use crate::modules::view::visual_style; let name = cmd.strip_prefix("VISUALSTYLES").unwrap_or("").trim(); match visual_style::mode_for_keyword(name) { Some(mode) => return Some(Task::done(Message::SetRenderMode(mode))), // Listed from the table, so the names offered are the names // that work. None => self .command_line .push_info(visual_style::keyword_prompt()), } } _ => return None, } Some(self.finish_dispatch(cmd)) } } // ── Draw Order: interactive command ────────────────────────── #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum DrawOrderStep { SelectObjects, ChooseVerb, PickReference { above: bool }, } /// Interactive front-end for the DRAWORDER command. /// /// Flow: /// 1. If nothing is selected, prompt to select objects (Enter confirms). /// 2. Prompt for verb: `[Above / Under / Front / Back]`. /// - `Front` / `F`: moves selection to front. /// - `Back` / `B` / Enter: moves selection to back. /// - `Above` / `A` / `Under` / `U`: advances to reference object pick. /// 3. Reference object pick: user can click the reference entity in the viewport /// or type its hex handle on the command line. pub(crate) struct DrawOrderCommand { selected: Vec, step: DrawOrderStep, } impl DrawOrderCommand { pub(crate) fn new(selected: Vec) -> Self { let step = if selected.is_empty() { DrawOrderStep::SelectObjects } else { DrawOrderStep::ChooseVerb }; Self { selected, step } } pub(crate) fn for_reference_pick(selected: Vec, above: bool) -> Self { Self { selected, step: DrawOrderStep::PickReference { above }, } } } impl CadCommand for DrawOrderCommand { fn name(&self) -> &'static str { "DRAWORDER" } fn prompt(&self) -> String { match self.step { DrawOrderStep::SelectObjects => { crate::t!("DRAWORDER select objects, then press Enter:").into_owned() } DrawOrderStep::ChooseVerb => { crate::t!("DRAWORDER [Above / Under / Front / Back] :").into_owned() } DrawOrderStep::PickReference { above: true } => { crate::t!("DRAWORDER Select reference object (move selection above):").into_owned() } DrawOrderStep::PickReference { above: false } => { crate::t!("DRAWORDER Select reference object (move selection under):").into_owned() } } } fn options(&self) -> Vec { match self.step { DrawOrderStep::ChooseVerb => vec![ crate::command::CmdOption::new("Above", "ABOVE"), crate::command::CmdOption::new("Under", "UNDER"), crate::command::CmdOption::new("Front", "FRONT"), crate::command::CmdOption::new("Back", "BACK"), ], _ => Vec::new(), } } fn wants_text_input(&self) -> bool { !matches!(self.step, DrawOrderStep::SelectObjects) } fn is_selection_gathering(&self) -> bool { matches!(self.step, DrawOrderStep::SelectObjects) } fn on_selection_complete(&mut self, handles: Vec) -> crate::command::CmdResult { self.selected = handles; crate::command::CmdResult::NeedPoint } fn on_enter(&mut self) -> crate::command::CmdResult { match self.step { DrawOrderStep::SelectObjects => { if self.selected.is_empty() { crate::command::CmdResult::Cancel } else { self.step = DrawOrderStep::ChooseVerb; crate::command::CmdResult::NeedPoint } } DrawOrderStep::ChooseVerb => { // Bare Enter defaults to Back let handles = std::mem::take(&mut self.selected); crate::command::CmdResult::Relaunch("DRAWORDER BACK".into(), handles) } DrawOrderStep::PickReference { .. } => crate::command::CmdResult::Cancel, } } fn on_text_input(&mut self, text: &str) -> Option { let t = text.trim(); if t.is_empty() { return None; } match self.step { DrawOrderStep::SelectObjects => None, DrawOrderStep::ChooseVerb => { let up = t.to_uppercase(); match up.as_str() { "F" | "FRONT" => { let handles = std::mem::take(&mut self.selected); Some(crate::command::CmdResult::Relaunch("DRAWORDER FRONT".into(), handles)) } "B" | "BACK" => { let handles = std::mem::take(&mut self.selected); Some(crate::command::CmdResult::Relaunch("DRAWORDER BACK".into(), handles)) } "A" | "ABOVE" => { self.step = DrawOrderStep::PickReference { above: true }; Some(crate::command::CmdResult::NeedPoint) } "U" | "UNDER" | "BELOW" => { self.step = DrawOrderStep::PickReference { above: false }; Some(crate::command::CmdResult::NeedPoint) } _ => Some(crate::command::CmdResult::NeedPoint), } } DrawOrderStep::PickReference { above } => { let hex_str = t.trim_start_matches("0x").trim_start_matches("0X"); if let Ok(val) = u64::from_str_radix(hex_str, 16) { let opt = if above { "A" } else { "U" }; let cmd = format!("DRAWORDER {} {:x}", opt, val); let handles = std::mem::take(&mut self.selected); Some(crate::command::CmdResult::Relaunch(cmd, handles)) } else { Some(crate::command::CmdResult::NeedPoint) } } } } fn needs_entity_pick(&self) -> bool { matches!(self.step, DrawOrderStep::PickReference { .. }) } fn on_entity_pick( &mut self, handle: acadrust::Handle, _pt: glam::DVec3, ) -> crate::command::CmdResult { if handle.is_null() { return crate::command::CmdResult::NeedPoint; } if let DrawOrderStep::PickReference { above } = self.step { let opt = if above { "A" } else { "U" }; let cmd = format!("DRAWORDER {} {:x}", opt, handle.value()); let handles = std::mem::take(&mut self.selected); crate::command::CmdResult::Relaunch(cmd, handles) } else { crate::command::CmdResult::NeedPoint } } fn on_point(&mut self, _pt: glam::DVec3) -> crate::command::CmdResult { crate::command::CmdResult::NeedPoint } } /// Sort-key assignments sending `group` to the back of the active space. /// /// Normal case: the floor is the lowest effective sort key among non-moved /// siblings, and there are enough unused slots below it — hand them out in /// ascending order so the group lands as one block strictly behind everything /// else with its internal stacking order preserved. /// /// Exhausted case (`floor <= group.len()`, e.g. a sibling already pinned at /// key 1 or 2): decrementing would clamp every member onto one tied key via /// `.max(1)`. Instead pin the group to keys 1..=n and lift every other /// in-space sibling whose effective key collides with that range above it, /// shifted by `n` so lifted siblings keep their relative order. Locked-layer /// entities are never moved or lifted. fn assign_back_group_keys( doc: &acadrust::CadDocument, block_handle: acadrust::Handle, group: &[acadrust::Handle], floor: u64, overrides: Option<&rustc_hash::FxHashMap>, locked_layers: &rustc_hash::FxHashSet<&str>, ) -> Vec<(acadrust::Handle, u64)> { let n = group.len() as u64; let mut out: Vec<(acadrust::Handle, u64)> = Vec::with_capacity(group.len()); if n == 0 { return out; } if floor > n { for (k, h) in group.iter().enumerate() { out.push((*h, floor - n + k as u64)); } return out; } for (k, h) in group.iter().enumerate() { out.push((*h, 1 + k as u64)); } let moved: rustc_hash::FxHashSet = group.iter().map(|h| h.value()).collect(); let has_locked = !locked_layers.is_empty(); let mut lifts: Vec<(acadrust::Handle, u64)> = Vec::new(); for e in doc.entities() { let c = e.common(); if c.owner_handle != block_handle && !c.owner_handle.is_null() { continue; } let hv = c.handle.value(); if moved.contains(&hv) { continue; } if has_locked && locked_layers.contains(c.layer.as_str()) { continue; } let eff = overrides.map_or(hv, |m| m.get(&hv).copied().unwrap_or(hv)); if eff <= n { lifts.push((c.handle, eff.saturating_add(n))); } } out.extend(lifts); out } #[cfg(test)] mod tests { use super::*; use crate::app::OpenCADStudio; use acadrust::objects::ObjectType; use acadrust::EntityType; // Benchmark, not a correctness test: measures end-to-end latency of // HATCHTOBACK and interactive DRAWORDER BACK on a large synthetic drawing. // Ignored by default; A/B across commits with: // BENCH_LINES=100000 BENCH_HATCHES=350 BENCH_RUNS=5 \ // cargo test --release --lib bench_draworder_large_drawing -- --ignored --nocapture #[test] #[ignore] fn bench_draworder_large_drawing() { let lines_n: usize = std::env::var("BENCH_LINES").ok().and_then(|v| v.parse().ok()).unwrap_or(100_000); let hatches_n: usize = std::env::var("BENCH_HATCHES").ok().and_then(|v| v.parse().ok()).unwrap_or(350); let runs: usize = std::env::var("BENCH_RUNS").ok().and_then(|v| v.parse().ok()).unwrap_or(5); let sel_n: usize = std::env::var("BENCH_SEL").ok().and_then(|v| v.parse().ok()).unwrap_or(100); fn median(v: &mut [f64]) -> f64 { v.sort_by(|a, b| a.partial_cmp(b).unwrap()); v[v.len() / 2] } // --- HATCHTOBACK --- let mut hb_ms: Vec = Vec::new(); for _ in 0..runs { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); let i = app.active_tab; let t0 = std::time::Instant::now(); let lines: Vec = (0..lines_n).map(|_| EntityType::Line(Default::default())).collect(); let _ = app.tabs[i].scene.add_entities(lines); let hatches: Vec = (0..hatches_n).map(|_| EntityType::Hatch(Default::default())).collect(); app.tabs[i].scene.add_entities(hatches); eprintln!( "[bench] setup ({} lines + {} hatches): {:.1} ms", lines_n, hatches_n, t0.elapsed().as_secs_f64() * 1e3 ); let t1 = std::time::Instant::now(); let _ = app.run_command_line("HATCHTOBACK"); hb_ms.push(t1.elapsed().as_secs_f64() * 1e3); } eprintln!("[bench] HATCHTOBACK median: {:.3} ms over {} runs", median(&mut hb_ms), runs); // --- DRAWORDER BACK (interactive path) --- let mut db_ms: Vec = Vec::new(); for _ in 0..runs { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); let i = app.active_tab; let lines: Vec = (0..lines_n).map(|_| EntityType::Line(Default::default())).collect(); let _ = app.tabs[i].scene.add_entities(lines); let hatches: Vec = (0..hatches_n).map(|_| EntityType::Hatch(Default::default())).collect(); let handles = app.tabs[i].scene.add_entities(hatches); app.tabs[i] .scene .replace_selection(handles.into_iter().take(sel_n).collect()); let t1 = std::time::Instant::now(); let _ = app.run_command_line("DRAWORDER BACK"); db_ms.push(t1.elapsed().as_secs_f64() * 1e3); } eprintln!( "[bench] DRAWORDER BACK ({} selected) median: {:.3} ms over {} runs", sel_n, median(&mut db_ms), runs ); } fn fresh_app() -> OpenCADStudio { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); app } #[test] fn hatchtoback_no_hatches_warns_and_noop() { let mut app = fresh_app(); let _ = app.run_command_line("HATCHTOBACK"); let i = app.active_tab; let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); let table_exists = app.tabs[i].scene.document.objects.values().any(|obj| { if let ObjectType::SortEntitiesTable(t) = obj { t.block_owner_handle == block_handle } else { false } }); assert!(!table_exists, "No SortEntitiesTable created when no hatches exist"); } #[test] fn hatchtoback_moves_hatches_to_back() { let mut app = fresh_app(); let i = app.active_tab; let h_line1 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); let h_line2 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); let _ = app.run_command_line("HATCHTOBACK"); let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); let table = app.tabs[i].scene.document.objects.values().find_map(|obj| { if let ObjectType::SortEntitiesTable(t) = obj { if t.block_owner_handle == block_handle { return Some(t); } } None }).expect("SortEntitiesTable should exist for the active layout block"); let entries: rustc_hash::FxHashMap = table .entries() .map(|e| (e.entity_handle.value(), e.sort_handle.value())) .collect(); let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); let line1_sort = entries.get(&h_line1.value()).copied().unwrap_or(h_line1.value()); let line2_sort = entries.get(&h_line2.value()).copied().unwrap_or(h_line2.value()); assert!( hatch_sort < line1_sort, "Hatch sort handle ({hatch_sort}) must be behind line 1 ({line1_sort})" ); assert!( hatch_sort < line2_sort, "Hatch sort handle ({hatch_sort}) must be behind line 2 ({line2_sort})" ); } #[test] fn hb_alias_moves_hatches_to_back() { let mut app = fresh_app(); let i = app.active_tab; let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); let _ = app.run_command_line("HB"); let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); let table = app.tabs[i].scene.document.objects.values().find_map(|obj| { if let ObjectType::SortEntitiesTable(t) = obj { if t.block_owner_handle == block_handle { return Some(t); } } None }).expect("SortEntitiesTable should exist for the active layout block"); let entries: rustc_hash::FxHashMap = table .entries() .map(|e| (e.entity_handle.value(), e.sort_handle.value())) .collect(); let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); assert!( hatch_sort < line_sort, "Hatch sort handle ({hatch_sort}) must be behind line ({line_sort}) via HB alias" ); } #[test] fn hatchtoback_ignores_hatches_in_other_spaces() { let mut app = fresh_app(); let i = app.active_tab; let other_block = acadrust::Handle::new(0x9999); let h_foreign = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(h_foreign) { entity.common_mut().owner_handle = other_block; } // When no hatches exist in the current layout, HATCHTOBACK should no-op. let _ = app.run_command_line("HATCHTOBACK"); let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); let table_exists = app.tabs[i].scene.document.objects.values().any(|obj| { if let ObjectType::SortEntitiesTable(t) = obj { t.block_owner_handle == block_handle } else { false } }); assert!(!table_exists, "Foreign hatch must not trigger table creation in active layout"); // Now add a line and a hatch in the active layout. let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); let _ = app.run_command_line("HATCHTOBACK"); let table = app.tabs[i].scene.document.objects.values().find_map(|obj| { if let ObjectType::SortEntitiesTable(t) = obj { if t.block_owner_handle == block_handle { return Some(t); } } None }).expect("SortEntitiesTable should exist for current layout"); let entries: rustc_hash::FxHashMap = table .entries() .map(|e| (e.entity_handle.value(), e.sort_handle.value())) .collect(); assert!(!entries.contains_key(&h_foreign.value()), "Foreign hatch must not be in active layout table"); assert!(entries.contains_key(&h_hatch.value()), "Active layout hatch must be in table"); let hatch_sort = entries[&h_hatch.value()]; let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); assert!(hatch_sort < line_sort); } #[test] fn hatchtoback_multiple_calls_are_idempotent() { let mut app = fresh_app(); let i = app.active_tab; let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); let h_hatch1 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); let h_hatch2 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); let _ = app.run_command_line("HATCHTOBACK"); let _ = app.run_command_line("HATCHTOBACK"); let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); let table = app.tabs[i].scene.document.objects.values().find_map(|obj| { if let ObjectType::SortEntitiesTable(t) = obj { if t.block_owner_handle == block_handle { return Some(t); } } None }).expect("SortEntitiesTable should exist"); let entries: rustc_hash::FxHashMap = table .entries() .map(|e| (e.entity_handle.value(), e.sort_handle.value())) .collect(); let h1_sort = entries.get(&h_hatch1.value()).copied().unwrap_or(h_hatch1.value()); let h2_sort = entries.get(&h_hatch2.value()).copied().unwrap_or(h_hatch2.value()); let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); assert!(h1_sort < line_sort); assert!(h2_sort < line_sort); } #[test] fn hatchtoback_preserves_active_selection() { let mut app = fresh_app(); let i = app.active_tab; let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); let _h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); // User had the line selected before running HATCHTOBACK. app.tabs[i].scene.replace_selection(std::iter::once(h_line).collect()); let _ = app.run_command_line("HATCHTOBACK"); let selected = app.tabs[i].scene.selected_handles_in_order(); assert_eq!(selected, vec![h_line], "HATCHTOBACK must not overwrite existing user selection"); } #[test] fn hatchtoback_skips_hatches_on_locked_layers() { let mut app = fresh_app(); let i = app.active_tab; // Lock layer "LOCKED_LAYER". app.tabs[i].scene.ensure_layer("LOCKED_LAYER"); if let Some(layer) = app.tabs[i].scene.document.layers.get_mut("LOCKED_LAYER") { layer.flags.locked = true; } let mut locked_hatch = acadrust::entities::Hatch::default(); locked_hatch.common.layer = "LOCKED_LAYER".into(); let h_locked_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(locked_hatch)); let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); // Run HATCHTOBACK when only a locked hatch exists: it should not move the locked hatch. let _ = app.run_command_line("HATCHTOBACK"); let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); let table_exists = app.tabs[i].scene.document.objects.values().any(|obj| { if let ObjectType::SortEntitiesTable(t) = obj { t.block_owner_handle == block_handle } else { false } }); assert!(!table_exists, "Locked hatch must not trigger table creation"); // Now add an unlocked hatch. let h_unlocked_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); let _ = app.run_command_line("HATCHTOBACK"); let table = app.tabs[i].scene.document.objects.values().find_map(|obj| { if let ObjectType::SortEntitiesTable(t) = obj { if t.block_owner_handle == block_handle { return Some(t); } } None }).expect("SortEntitiesTable should exist for unlocked hatch"); let entries: rustc_hash::FxHashMap = table .entries() .map(|e| (e.entity_handle.value(), e.sort_handle.value())) .collect(); assert!(!entries.contains_key(&h_locked_hatch.value()), "Locked hatch must not be reordered"); assert!(entries.contains_key(&h_unlocked_hatch.value()), "Unlocked hatch must be reordered"); let hatch_sort = entries[&h_unlocked_hatch.value()]; let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); assert!(hatch_sort < line_sort); } // HATCHTOBACK must preserve the moved group's internal stacking order. // Written while auditing the descending key assignment, which silently // REVERSES hatch-to-hatch order; this test fails while that holds. #[test] fn hatchtoback_preserves_hatch_stacking_order() { let mut app = fresh_app(); let i = app.active_tab; let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); let h_hatch1 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); let h_hatch2 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); let h_hatch3 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); let _ = app.run_command_line("HATCHTOBACK"); let entries = effective_sort_map(&app); let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); let s1 = entries.get(&h_hatch1.value()).copied().unwrap_or(h_hatch1.value()); let s2 = entries.get(&h_hatch2.value()).copied().unwrap_or(h_hatch2.value()); let s3 = entries.get(&h_hatch3.value()).copied().unwrap_or(h_hatch3.value()); assert!(s3 > s2 && s2 > s1, "stacking order must be preserved, got {s1}, {s2}, {s3}"); assert!(s1 < line_sort && s2 < line_sort && s3 < line_sort, "all hatches must stay behind the line"); } // When the key space below the floor is exhausted (an entity already sits // at a very low sort key), HATCHTOBACK must not clamp every hatch onto one // tied key: hatches stay pairwise distinct and no sibling may sink below // them. Fails while `.max(1)` clamping is in place. #[test] fn hatchtoback_exhausted_floor_keeps_hatches_distinct_and_behind() { let mut app = fresh_app(); let i = app.active_tab; let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); let h_hatch1 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); let h_hatch2 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); let h_hatch3 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); // Pin the line to sort key 2: only ONE slot below the floor exists, // but three hatches need to fit behind it. { use acadrust::objects::{ObjectType, SortEntitiesTable}; let doc = &mut app.tabs[i].scene.document; let nh = acadrust::Handle::new(doc.next_handle()); let mut table = SortEntitiesTable::for_block(block_handle); table.handle = nh; table.add_entry(h_line, acadrust::Handle::new(2)); doc.objects.insert(nh, ObjectType::SortEntitiesTable(table)); } let _ = app.run_command_line("HATCHTOBACK"); let entries = effective_sort_map(&app); let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); let s1 = entries.get(&h_hatch1.value()).copied().unwrap_or(h_hatch1.value()); let s2 = entries.get(&h_hatch2.value()).copied().unwrap_or(h_hatch2.value()); let s3 = entries.get(&h_hatch3.value()).copied().unwrap_or(h_hatch3.value()); assert_ne!(s1, s2, "hatches must not tie on one clamped key"); assert_ne!(s1, s3, "hatches must not tie on one clamped key"); assert_ne!(s2, s3, "hatches must not tie on one clamped key"); assert!( line_sort > s1.max(s2).max(s3), "the pinned line ({line_sort}) must still render above every hatch ({s1}, {s2}, {s3})" ); } #[test] fn hatchtoback_undo_restores_draw_order() { let mut app = fresh_app(); let i = app.active_tab; let _h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); let _ = app.run_command_line("HATCHTOBACK"); let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); let table = app.tabs[i].scene.document.objects.values().find_map(|obj| { if let ObjectType::SortEntitiesTable(t) = obj { if t.block_owner_handle == block_handle { return Some(t); } } None }).expect("SortEntitiesTable should exist"); assert!(table.entries().any(|e| e.entity_handle == h_hatch)); // Perform UNDO. let _ = app.update(crate::app::Message::Undo); let table_after_undo = app.tabs[i].scene.document.objects.values().find_map(|obj| { if let ObjectType::SortEntitiesTable(t) = obj { if t.block_owner_handle == block_handle { return Some(t); } } None }); // Table was created fresh by HATCHTOBACK, so undo should remove it or leave it empty of the hatch. let has_hatch = table_after_undo.map_or(false, |t| t.entries().any(|e| e.entity_handle == h_hatch)); assert!(!has_hatch, "Undo must revert the SortEntitiesTable entry"); } fn effective_sort_map( app: &OpenCADStudio, ) -> rustc_hash::FxHashMap { let i = app.active_tab; let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); app.tabs[i] .scene .document .objects .values() .find_map(|obj| { if let ObjectType::SortEntitiesTable(t) = obj { if t.block_owner_handle == block_handle { return Some( t.entries() .map(|e| (e.entity_handle.value(), e.sort_handle.value())) .collect(), ); } } None }) .unwrap_or_default() } // Regression guard for DRAWORDER BACK anchoring. Written while auditing // the draw-order code, where BACK *looked* like it skipped the baseline // scan and clamped every moved entity onto sort key 1 (a tie). Running // this test disproved that — `to_front_opt` is Some(false) for BACK, so // the baseline scan does run. Kept to lock in the strict-order behavior: // a second BACK must land strictly below earlier assignments, never tie. #[test] fn draworder_back_twice_keeps_strict_order() { let mut app = fresh_app(); let i = app.active_tab; let h_line1 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); // Send the hatch to back, then send line 1 behind it: the second BACK // must anchor strictly below the first assignment, never tie with it. app.tabs[i].scene.replace_selection(std::iter::once(h_hatch).collect()); let _ = app.run_command_line("DRAWORDER BACK"); app.tabs[i].scene.replace_selection(std::iter::once(h_line1).collect()); let _ = app.run_command_line("DRAWORDER BACK"); let entries = effective_sort_map(&app); let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); let line1_sort = entries.get(&h_line1.value()).copied().unwrap_or(h_line1.value()); assert!( line1_sort < hatch_sort, "second BACK must land strictly below the first ({line1_sort} vs {hatch_sort})" ); } // Regression test for a suspected multi-select tie in DRAWORDER // ABOVE/UNDER: the loop called move_above/move_below per entity, and each // call recomputes target±1, so N selected entities should all collapse to // one key. This test must fail while that bug exists. // Same floor-exhaustion scenario as hatchtoback_exhausted_floor_…, but // through the interactive DRAWORDER BACK path: two selected lines must // land distinct and strictly behind a sibling pinned at key 2, instead of // both clamping onto key 1. #[test] fn draworder_back_exhausted_floor_keeps_distinct_and_behind() { let mut app = fresh_app(); let i = app.active_tab; let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); let h_pinned = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); let h_line1 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); let h_line2 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); { use acadrust::objects::{ObjectType, SortEntitiesTable}; let doc = &mut app.tabs[i].scene.document; let nh = acadrust::Handle::new(doc.next_handle()); let mut table = SortEntitiesTable::for_block(block_handle); table.handle = nh; table.add_entry(h_pinned, acadrust::Handle::new(2)); doc.objects.insert(nh, ObjectType::SortEntitiesTable(table)); } app.tabs[i] .scene .replace_selection([h_line1, h_line2].into_iter().collect()); let _ = app.run_command_line("DRAWORDER BACK"); let entries = effective_sort_map(&app); let pinned_sort = entries.get(&h_pinned.value()).copied().unwrap_or(h_pinned.value()); let s1 = entries.get(&h_line1.value()).copied().unwrap_or(h_line1.value()); let s2 = entries.get(&h_line2.value()).copied().unwrap_or(h_line2.value()); assert_ne!(s1, s2, "BACK must not clamp both selected entities onto one tied key"); assert!( pinned_sort > s1.max(s2), "pinned sibling ({pinned_sort}) must stay above the moved pair ({s1}, {s2})" ); } #[test] fn draworder_above_multi_select_keeps_distinct_order() { let mut app = fresh_app(); let i = app.active_tab; let h_ref = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); let h_hatch1 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); let h_hatch2 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); app.tabs[i] .scene .replace_selection([h_hatch1, h_hatch2].into_iter().collect()); let cmd = format!("DRAWORDER ABOVE {:x}", h_ref.value()); let _ = app.run_command_line(&cmd); let entries = effective_sort_map(&app); let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); let hatch1_sort = entries.get(&h_hatch1.value()).copied().unwrap_or(h_hatch1.value()); let hatch2_sort = entries.get(&h_hatch2.value()).copied().unwrap_or(h_hatch2.value()); assert!(hatch1_sort > ref_sort, "hatch 1 ({hatch1_sort}) must be above reference ({ref_sort})"); assert!(hatch2_sort > ref_sort, "hatch 2 ({hatch2_sort}) must be above reference ({ref_sort})"); assert_ne!(hatch1_sort, hatch2_sort, "multi-select ABOVE must not tie selected entities"); assert!( hatch2_sort > hatch1_sort, "selection order must be preserved within the moved group ({hatch1_sort}, {hatch2_sort})" ); } #[test] fn draworder_under_multi_select_keeps_distinct_order() { let mut app = fresh_app(); let i = app.active_tab; let h_ref = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); let h_hatch1 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); let h_hatch2 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); app.tabs[i] .scene .replace_selection([h_hatch1, h_hatch2].into_iter().collect()); let cmd = format!("DRAWORDER UNDER {:x}", h_ref.value()); let _ = app.run_command_line(&cmd); let entries = effective_sort_map(&app); let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); let hatch1_sort = entries.get(&h_hatch1.value()).copied().unwrap_or(h_hatch1.value()); let hatch2_sort = entries.get(&h_hatch2.value()).copied().unwrap_or(h_hatch2.value()); assert!(hatch1_sort < ref_sort, "hatch 1 ({hatch1_sort}) must be under reference ({ref_sort})"); assert!(hatch2_sort < ref_sort, "hatch 2 ({hatch2_sort}) must be under reference ({ref_sort})"); assert_ne!(hatch1_sort, hatch2_sort, "multi-select UNDER must not tie selected entities"); assert!( hatch1_sort > hatch2_sort, "selection order must be preserved within the moved group ({hatch1_sort}, {hatch2_sort})" ); } #[test] fn draworder_front_moves_hatch_in_front_of_all() { let mut app = fresh_app(); let i = app.active_tab; let h_line1 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); let h_line2 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); app.tabs[i].scene.replace_selection(std::iter::once(h_hatch).collect()); let _ = app.run_command_line("DRAWORDER FRONT"); let entries = effective_sort_map(&app); let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); let line1_sort = entries.get(&h_line1.value()).copied().unwrap_or(h_line1.value()); let line2_sort = entries.get(&h_line2.value()).copied().unwrap_or(h_line2.value()); assert!(hatch_sort > line1_sort, "hatch ({hatch_sort}) must render in front of line 1 ({line1_sort})"); assert!(hatch_sort > line2_sort, "hatch ({hatch_sort}) must render in front of line 2 ({line2_sort})"); } #[test] fn draworder_back_moves_hatch_behind_all() { let mut app = fresh_app(); let i = app.active_tab; let h_line1 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); let h_line2 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); app.tabs[i].scene.replace_selection(std::iter::once(h_hatch).collect()); let _ = app.run_command_line("DRAWORDER BACK"); let entries = effective_sort_map(&app); let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); let line1_sort = entries.get(&h_line1.value()).copied().unwrap_or(h_line1.value()); let line2_sort = entries.get(&h_line2.value()).copied().unwrap_or(h_line2.value()); assert!(hatch_sort < line1_sort, "hatch ({hatch_sort}) must render behind line 1 ({line1_sort})"); assert!(hatch_sort < line2_sort, "hatch ({hatch_sort}) must render behind line 2 ({line2_sort})"); } #[test] fn draworder_above_reference_puts_hatch_in_front_of_object() { let mut app = fresh_app(); let i = app.active_tab; let h_ref = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); app.tabs[i].scene.replace_selection(std::iter::once(h_hatch).collect()); let cmd = format!("DRAWORDER ABOVE {:x}", h_ref.value()); let _ = app.run_command_line(&cmd); let entries = effective_sort_map(&app); let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); assert!(hatch_sort > ref_sort, "hatch ({hatch_sort}) must be above reference object ({ref_sort})"); } #[test] fn draworder_under_reference_puts_hatch_behind_object() { let mut app = fresh_app(); let i = app.active_tab; let h_ref = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); app.tabs[i].scene.replace_selection(std::iter::once(h_hatch).collect()); let cmd = format!("DRAWORDER UNDER {:x}", h_ref.value()); let _ = app.run_command_line(&cmd); let entries = effective_sort_map(&app); let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); assert!(hatch_sort < ref_sort, "hatch ({hatch_sort}) must be under reference object ({ref_sort})"); } #[test] fn draworder_interactive_shortcuts_f_b_a_u() { use crate::command::CadCommand; let mut app = fresh_app(); let i = app.active_tab; let _h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); // 1. Shortcut 'F' -> Front let mut cmd = DrawOrderCommand::new(vec![h_hatch]); assert_eq!(cmd.wants_text_input(), true); let res = cmd.on_text_input("F"); match res { Some(crate::command::CmdResult::Relaunch(c, handles)) => { assert_eq!(c, "DRAWORDER FRONT"); assert_eq!(handles, vec![h_hatch]); } _ => panic!("Expected Relaunch for shortcut F"), } // 2. Shortcut 'B' -> Back let mut cmd = DrawOrderCommand::new(vec![h_hatch]); let res = cmd.on_text_input("B"); match res { Some(crate::command::CmdResult::Relaunch(c, handles)) => { assert_eq!(c, "DRAWORDER BACK"); assert_eq!(handles, vec![h_hatch]); } _ => panic!("Expected Relaunch for shortcut B"), } // 3. Shortcut 'A' -> Advances to PickReference (above) let mut cmd = DrawOrderCommand::new(vec![h_hatch]); assert!(!cmd.needs_entity_pick()); let res = cmd.on_text_input("A"); assert!(matches!(res, Some(crate::command::CmdResult::NeedPoint))); assert!(cmd.needs_entity_pick(), "Needs entity pick after choosing 'A'"); // 4. Shortcut 'U' -> Advances to PickReference (under) let mut cmd = DrawOrderCommand::new(vec![h_hatch]); let res = cmd.on_text_input("U"); assert!(matches!(res, Some(crate::command::CmdResult::NeedPoint))); assert!(cmd.needs_entity_pick(), "Needs entity pick after choosing 'U'"); } #[test] fn draworder_interactive_above_under_viewport_entity_pick() { use crate::command::CadCommand; let mut app = fresh_app(); let i = app.active_tab; let h_ref = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); // Above with viewport pick let mut cmd = DrawOrderCommand::new(vec![h_hatch]); let _ = cmd.on_text_input("A"); assert!(cmd.needs_entity_pick()); let pick_res = cmd.on_entity_pick(h_ref, glam::DVec3::ZERO); match pick_res { crate::command::CmdResult::Relaunch(relaunch_cmd, handles) => { assert_eq!(relaunch_cmd, format!("DRAWORDER A {:x}", h_ref.value())); assert_eq!(handles, vec![h_hatch]); app.tabs[i].scene.replace_selection(handles.into_iter().collect()); let _ = app.run_command_line(&relaunch_cmd); } _ => panic!("Expected Relaunch from entity pick"), } let entries = effective_sort_map(&app); let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); assert!(hatch_sort > ref_sort, "Hatch must be above reference after viewport pick"); // Under with viewport pick let mut cmd = DrawOrderCommand::new(vec![h_hatch]); let _ = cmd.on_text_input("U"); assert!(cmd.needs_entity_pick()); let pick_res = cmd.on_entity_pick(h_ref, glam::DVec3::ZERO); match pick_res { crate::command::CmdResult::Relaunch(relaunch_cmd, handles) => { assert_eq!(relaunch_cmd, format!("DRAWORDER U {:x}", h_ref.value())); assert_eq!(handles, vec![h_hatch]); app.tabs[i].scene.replace_selection(handles.into_iter().collect()); let _ = app.run_command_line(&relaunch_cmd); } _ => panic!("Expected Relaunch from entity pick"), } let entries = effective_sort_map(&app); let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); assert!(hatch_sort < ref_sort, "Hatch must be under reference after viewport pick"); } #[test] fn draworder_interactive_typed_hex_handle() { use crate::command::CadCommand; let mut app = fresh_app(); let i = app.active_tab; let h_ref = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); let mut cmd = DrawOrderCommand::new(vec![h_hatch]); let _ = cmd.on_text_input("Above"); let hex_input = format!("0x{:x}", h_ref.value()); let typed_res = cmd.on_text_input(&hex_input); match typed_res { Some(crate::command::CmdResult::Relaunch(relaunch_cmd, handles)) => { assert_eq!(relaunch_cmd, format!("DRAWORDER A {:x}", h_ref.value())); assert_eq!(handles, vec![h_hatch]); app.tabs[i].scene.replace_selection(handles.into_iter().collect()); let _ = app.run_command_line(&relaunch_cmd); } _ => panic!("Expected Relaunch from typed hex handle"), } let entries = effective_sort_map(&app); let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); assert!(hatch_sort > ref_sort); } #[test] fn draworder_interactive_without_preselection() { use crate::command::CadCommand; let mut app = fresh_app(); let i = app.active_tab; let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); // Start command with no pre-selection let mut cmd = DrawOrderCommand::new(vec![]); assert!(cmd.is_selection_gathering()); assert!(!cmd.wants_text_input()); // Gather selection let _ = cmd.on_selection_complete(vec![h_hatch]); let enter_res = cmd.on_enter(); assert!(matches!(enter_res, crate::command::CmdResult::NeedPoint)); assert!(!cmd.is_selection_gathering()); assert!(cmd.wants_text_input()); // Choose verb Front let res = cmd.on_text_input("F"); match res { Some(crate::command::CmdResult::Relaunch(relaunch_cmd, handles)) => { assert_eq!(relaunch_cmd, "DRAWORDER FRONT"); assert_eq!(handles, vec![h_hatch]); app.tabs[i].scene.replace_selection(handles.into_iter().collect()); let _ = app.run_command_line(&relaunch_cmd); } _ => panic!("Expected Relaunch"), } let entries = effective_sort_map(&app); let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); assert!(hatch_sort > line_sort); } }