Files
OneCAD-Draft/src/app/commands/view.rs
T
Hakan SevenandClaude Opus 5.5 ff6e2a6613 review(#1660, #1663): associative sweep scan, arc angles, viewport off bit
- refresh_associative_sweeps runs on every edit and scanned the xdata of
  every entity; it now returns at once when no associative swept surface
  exists (its application is not registered) and looks at surfaces only.
- An arc's end angle was raised by TAU in a loop, which never ends on a
  huge angle from a damaged file; the sweep is taken with rem_euclid.
- The sweep DELOBJ test expected surface sweeps to delete their sources,
  which #1663 changed on purpose: an associative surface keeps them.
- codec c9ffcac (opencadcodec #103) adds an explicit viewport-off bit: the
  viewport on state is read with is_on() and set with turn_on()/turn_off()
  everywhere, so a viewport the file marks off shows off and can be turned
  back on from Properties.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 14:56:30 +03:00

2535 lines
119 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
// Sort tables belong to the block's extension dictionary. Reserve real handles
// and reconnect older unattached tables without losing their existing entries.
fn ensure_draw_order_table(
doc: &mut codec::CadDocument,
block: codec::Handle,
) -> codec::Handle {
use codec::objects::{Dictionary, ObjectType, SortEntitiesTable};
// Older commands inserted objects using the unreserved next-handle value.
// Repair the allocator floor before creating anything beside those objects.
if let Some(maximum) = doc.objects.keys().map(|handle| handle.value()).max() {
doc.header.handle_seed = doc.header.handle_seed.max(maximum.saturating_add(1));
}
let dictionary = doc.extension_dictionary_handle(block).filter(|handle| {
matches!(doc.objects.get(handle), Some(ObjectType::Dictionary(_)))
});
let named_table = dictionary.and_then(|handle| match doc.objects.get(&handle) {
Some(ObjectType::Dictionary(value)) => value.get(SortEntitiesTable::DICTIONARY_KEY),
_ => None,
}).filter(|handle| matches!(doc.objects.get(handle),
Some(ObjectType::SortEntitiesTable(table)) if table.block_owner_handle == block));
let existing = named_table.or_else(|| doc.objects.iter().find_map(|(handle, object)| {
match object {
ObjectType::SortEntitiesTable(table) if table.block_owner_handle == block => Some(*handle),
_ => None,
}
}));
let dictionary = dictionary.unwrap_or_else(|| {
let handle = doc.allocate_handle();
let mut value = Dictionary::new();
value.handle = handle;
value.owner = block;
value.hard_owner = true;
doc.objects.insert(handle, ObjectType::Dictionary(value));
handle
});
let handle = existing.unwrap_or_else(|| {
let handle = doc.allocate_handle();
let mut table = SortEntitiesTable::for_block(block);
table.handle = handle;
doc.objects.insert(handle, ObjectType::SortEntitiesTable(table));
handle
});
if let Some(ObjectType::SortEntitiesTable(table)) = doc.objects.get_mut(&handle) {
table.owner_handle = dictionary;
}
if let Some(ObjectType::Dictionary(value)) = doc.objects.get_mut(&dictionary) {
if let Some((_, entry)) = value.entries.iter_mut()
.find(|(name, _)| name.eq_ignore_ascii_case(SortEntitiesTable::DICTIONARY_KEY)) {
*entry = handle;
} else {
value.add_entry(SortEntitiesTable::DICTIONARY_KEY, handle);
}
}
handle
}
impl OpenCADStudio {
pub(crate) fn dispatch_view(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
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<usize> = 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::<f64>() {
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> <real>").as_ref());
}
}
(Some(n @ 1..=5), v, false) => {
if let Ok(val) = v.parse::<i16>() {
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> <integer>").as_ref());
}
}
_ => self
.command_line
.push_info(crate::t!("Usage: USERI <1-5> <int> | USERR <1-5> <real>").as_ref()),
}
}
"REPORT" => {
// Pre-fill the GitHub issue body with version + platform so
// reports arrive with the basics already filled in.
let body = format!(
"<!-- Describe the issue and the steps to reproduce it. -->\n\n\n\
---\n- Open CAD Studio: v{}\n- Revision: {} ({}, {})\n- Platform: {}\n",
env!("OCS_FULL_VERSION"),
env!("OCS_GIT_REV"),
env!("OCS_COMMIT_DATE"),
env!("OCS_BUILD_PROFILE"),
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));
}
// ── OPTIONS / OP — the preferences dialog ──────────────────────
// Both names were registered for autocomplete but never dispatched,
// so typing either one answered "Unknown command" and the dialog
// could only be reached from the button on the start page.
"OPTIONS" | "OP" => {
return Some(Task::done(Message::OptionsOpen));
}
"PARAMETERS" => {
return Some(Task::done(Message::NamedParametersOpen));
}
// CUIEXPORT <path> — 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(
crate::t!("Usage: CUIEXPORT <path> — save the keyboard shortcuts to a file.").as_ref(),
);
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 <path> — 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(
crate::t!("Usage: CUIIMPORT <path> — load keyboard shortcuts from a file.").as_ref(),
);
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 <key> <command>
// 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 <key> <command> 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 <key> <command> 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 <key>").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 <key> <cmd> | CLEAR <key>").as_ref());
}
}
}
// ── Color Scheme / Theme selector ─────────────────────────────
cmd if cmd.eq_ignore_ascii_case("COLORSCHEME") => {
use crate::command::ValuePromptCommand;
let c = ValuePromptCommand::new(
"COLORSCHEME",
"COLORSCHEME Enter theme name (or ? to list):",
);
self.command_line.push_output(&crate::tf!("Available themes: {}",
"DARK LIGHT DRACULA NORD SOLARIZED_LIGHT SOLARIZED_DARK \
GRUVBOX_LIGHT GRUVBOX_DARK TOKYONIGHT TOKYONIGHTSTORM TOKYONIGHTLIGHT \
KANAGAWAWAVE KANAGAWADRAGON KANAGAWALOTUS MOONFLY NIGHTFLY OXOCARBON FERRA",
));
self.command_line.push_info(&crate::tf!(
"Current color scheme: <{}>",
self.ui_theme.name
));
self.command_line.push_info(&c.prompt());
self.tabs[i].active_cmd = Some(Box::new(c));
return Some(Task::none());
}
cmd if cmd.to_ascii_uppercase().starts_with("COLORSCHEME ") => {
let sub = cmd.split_once(' ').map(|(_, r)| r.trim()).unwrap_or("");
let upper_sub = sub.to_ascii_uppercase();
if upper_sub.is_empty() {
self.command_line.push_output(&crate::tf!(
"Current color scheme: <{}>",
self.ui_theme.name
));
return Some(Task::none());
}
if upper_sub == "LIST" || upper_sub == "?" {
self.command_line.push_output(&crate::tf!("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());
}
if sub == "0" || sub == "1" {
self.command_line.push_error(
crate::t!("COLORSCHEME: 0 and 1 are COLORTHEME options. Enter a theme name (e.g. DARK, LIGHT, DRACULA) or use COLORTHEME.").as_ref(),
);
return Some(Task::none());
}
let theme = crate::app::config::parse_theme_name(sub);
if let Some(t) = theme {
if self.ui_theme.name == "Custom" {
self.saved_custom_palette = Some(self.ui_theme.palette);
}
let name = format!("{:?}", t);
self.ui_theme.name = t.to_string();
self.ui_theme.palette =
crate::app::config::UiThemePalette::from_iced(t.seed());
self.theme_color_inputs = self.ui_theme.palette.hex_values();
self.active_theme = t.clone();
self.sync_model_space_theme(true);
self.persist_settings_if_changed();
self.command_line
.push_output(crate::tf!("Color scheme set to '{name}'.").as_ref());
return Some(Task::none());
} else {
self.command_line.push_error(crate::tf!(
"COLORSCHEME: unknown theme '{}'. Type COLORSCHEME LIST for options.",
sub
).as_ref());
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));
}
// ── CHSPACE — move objects between model and paper space through
// a viewport, keeping their apparent size and place (#1428).
// Inside a viewport the selection goes to the sheet; on the
// sheet it goes into the model through the viewport under it.
"CHSPACE" => {
let handles: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.into_iter()
.map(|(h, _)| h)
.collect();
if self.tabs[i].scene.current_layout == "Model" {
self.command_line
.push_error(crate::t!("CHSPACE: Command not allowed in the Model tab.").as_ref());
return Some(Task::none());
}
if handles.is_empty() {
use crate::modules::draw::select::SelectObjectsCommand;
let cmd = SelectObjectsCommand::new("CHSPACE");
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
return Some(Task::none());
}
let scene = &self.tabs[i].scene;
let to_paper = scene.active_viewport.is_some();
let frame = match scene.active_viewport {
Some(vp) => scene.viewport_frame(vp),
None => {
// The viewport under the middle of the selection.
let (mut lo, mut hi) = (glam::DVec2::MAX, glam::DVec2::MIN);
for wire in scene.wire_models_for(&handles) {
let [x0, y0, x1, y1] = wire.aabb.map(f64::from);
if [x0, y0, x1, y1].iter().all(|v| v.is_finite()) {
lo = lo.min(glam::DVec2::new(x0, y0));
hi = hi.max(glam::DVec2::new(x1, y1));
}
}
let mid = (lo + hi) * 0.5;
mid.is_finite()
.then(|| scene.viewport_frames_at_paper_point(mid.extend(0.0)))
.and_then(|frames| frames.into_iter().next())
}
};
let Some(frame) = frame else {
self.command_line.push_error(
crate::t!("CHSPACE: No plan-view viewport to change space through.").as_ref(),
);
return Some(Task::none());
};
use crate::command::EntityTransform as T;
let z = glam::DVec3::Z;
let steps = if to_paper {
[
T::Translate((-frame.model_target).extend(0.0)),
T::Rotate { center: glam::DVec3::ZERO, axis: z, angle_rad: frame.twist },
T::Scale { center: glam::DVec3::ZERO, factor: frame.scale },
T::Translate(frame.paper_center.extend(0.0)),
]
} else {
[
T::Translate((-frame.paper_center).extend(0.0)),
T::Scale { center: glam::DVec3::ZERO, factor: 1.0 / frame.scale },
T::Rotate { center: glam::DVec3::ZERO, axis: z, angle_rad: -frame.twist },
T::Translate(frame.model_target.extend(0.0)),
]
};
self.push_undo_snapshot(i, "CHSPACE");
let scene = &mut self.tabs[i].scene;
let entities: Vec<codec::EntityType> = handles
.iter()
.filter_map(|h| scene.document.get_entity(*h).cloned())
.collect();
scene.deselect_all();
scene.erase_entities(&handles);
// The clones land in the other space: the sheet with no active
// viewport, the model with one.
let active = scene.active_viewport;
scene.active_viewport = if to_paper { None } else { Some(frame.viewport) };
let moved: Vec<codec::Handle> =
entities.into_iter().map(|e| scene.add_entity_clone(e)).collect();
scene.active_viewport = active;
for step in &steps {
scene.transform_entities(&moved, step);
}
self.tabs[i].dirty = true;
let count = moved.len();
self.command_line.push_output(
if to_paper {
crate::tf!("CHSPACE: {count} object(s) changed to paper space.")
} else {
crate::tf!("CHSPACE: {count} object(s) changed to model space.")
}
.as_ref(),
);
}
// ── 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>, 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 codec::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.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(
crate::t!("VPORTS: unknown option. Use VPORTS 2H | 2V | 4 | SINGLE").as_ref(),
);
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<codec::Handle> = self.tabs[i]
.scene
.document
.entities()
.filter_map(|e| {
if let codec::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 = codec::entities::Viewport::new();
vp.center = codec::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(
codec::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<codec::Handle> = self.tabs[i]
.scene
.document
.entities()
.filter_map(|e| {
if let codec::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(codec::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<String> = {
if let Some(codec::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<codec::Handle> = self.tabs[i]
.scene
.document
.entities()
.filter(|e| {
let c = e.common();
(c.owner_handle == block_handle || c.owner_handle.is_null())
&& matches!(
e,
codec::EntityType::Text(_)
| codec::EntityType::MText(_)
| codec::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 codec::objects::ObjectType;
let block_handle = self.tabs[i].scene.current_layout_block_handle_pub();
let doc_ref = &self.tabs[i].scene.document;
// 1. Read existing SortEntitiesTable overrides.
let mut overrides: Option<rustc_hash::FxHashMap<u64, u64>> = None;
for obj in doc_ref.objects.values() {
if let ObjectType::SortEntitiesTable(t) = obj {
if t.block_owner_handle == block_handle {
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<codec::Handle> = 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, codec::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,
);
// Dictionary ownership and handle allocation are part of this undo step.
let pending_delta = self.begin_undo(i, "DRAWORDER", hatches_to_move.len(), false);
let th = ensure_draw_order_table(&mut self.tabs[i].scene.document, block_handle);
if let Some(ObjectType::SortEntitiesTable(table)) =
self.tabs[i].scene.document.objects.get_mut(&th)
{
for (h, sort) in &assigns {
table.add_entry(*h, codec::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<(codec::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_FRONT" | "DRAWORDER_BACK" | "DRAWORDER_ABOVE" | "DRAWORDER_UNDER" => {
let selected: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
.map(|(h, _)| *h)
.collect();
if selected.is_empty() {
use crate::modules::draw::select::SelectObjectsCommand;
let selection = SelectObjectsCommand::plain("DRAWORDER", cmd);
self.command_line.push_info(&selection.prompt());
self.tabs[i].active_cmd = Some(Box::new(selection));
} else if matches!(cmd, "DRAWORDER_ABOVE" | "DRAWORDER_UNDER") {
let command = DrawOrderCommand::for_reference_pick(
selected,
cmd == "DRAWORDER_ABOVE",
);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
} else {
let primitive = if cmd == "DRAWORDER_FRONT" {
"DRAWORDER FRONT"
} else {
"DRAWORDER BACK"
};
return Some(self.apply_cmd_result(crate::command::CmdResult::Relaunch(
primitive.to_string(),
selected,
)));
}
}
"DRAWORDER" => {
let selected: Vec<codec::Handle> = 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 codec::objects::ObjectType;
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<codec::Handle> = 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 {
let relative_above = match option.as_str() {
"A" | "ABOVE" => Some(true),
"U" | "UNDER" | "BELOW" => Some(false),
_ => None,
};
let references: Vec<_> = parts.iter().skip(2).filter_map(|text| {
u64::from_str_radix(text.trim_start_matches("0x").trim_start_matches("0X"), 16)
.ok().map(codec::Handle::new)
}).filter(|handle| !selected.contains(handle)).collect();
let relative_assignments = relative_above.and_then(|above| {
assign_relative_group_keys(
&self.tabs[i].scene.document,
self.tabs[i].scene.current_layout_block_handle_pub(),
&selected, &references, above,
)
});
let to_front_opt = match option.as_str() {
"F" | "FRONT" => Some(true),
"B" | "BACK" => Some(false),
_ => None,
};
if relative_assignments.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<Vec<(codec::Handle, u64)>> = None;
let fb_baseline: Option<(u64, u64)> = if to_front_opt.is_some() {
let selected_set: rustc_hash::FxHashSet<u64> =
selected.iter().map(|h| h.value()).collect();
let doc_ref = &self.tabs[i].scene.document;
let overrides: rustc_hash::FxHashMap<u64, u64> = 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 th = ensure_draw_order_table(doc, block_handle);
if let Some(ObjectType::SortEntitiesTable(table)) = doc.objects.get_mut(&th)
{
if let Some(assignments) = &relative_assignments {
for (handle, sort) in assignments {
table.add_entry(*handle, codec::Handle::new(*sort));
}
self.command_line.push_info(crate::t!("DRAWORDER: selection reordered relative to reference objects.").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, codec::Handle::new(sort));
}
} else if let Some(assigns) = &back_assigns {
for (h, sort) in assigns {
table.add_entry(*h, codec::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 <handle> | U|UNDER <handle>").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<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
.filter(|(_, e)| matches!(e, codec::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(
crate::t!("SYNCPVIEWPORTS: select two or more viewports (the first is the master).").as_ref(),
);
return Some(Task::none());
}
let src = match self.tabs[i].scene.document.get_entity(vps[0]) {
Some(codec::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(codec::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(
codec::entities::ViewportRenderMode::HiddenLine,
)));
}
// VISUALSTYLES <name> — 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(crate::t!(visual_style::keyword_prompt()).as_ref()),
}
}
_ => 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. Gather reference objects, excluding the moved selection; Enter applies.
/// Typed hexadecimal handles may also be accumulated before confirming.
pub(crate) struct DrawOrderCommand {
selected: Vec<codec::Handle>,
step: DrawOrderStep,
references: Vec<codec::Handle>,
}
impl DrawOrderCommand {
pub(crate) fn new(selected: Vec<codec::Handle>) -> Self {
let step = if selected.is_empty() {
DrawOrderStep::SelectObjects
} else {
DrawOrderStep::ChooseVerb
};
Self { selected, step, references: Vec::new() }
}
pub(crate) fn for_reference_pick(selected: Vec<codec::Handle>, above: bool) -> Self {
Self {
selected,
step: DrawOrderStep::PickReference { above },
references: Vec::new(),
}
}
}
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] <Back>:").into_owned()
}
DrawOrderStep::PickReference { above: true } => {
format!("DRAWORDER Select reference objects to move above ({} selected, Enter when done):", self.references.len())
}
DrawOrderStep::PickReference { above: false } => {
format!("DRAWORDER Select reference objects to move under ({} selected, Enter when done):", self.references.len())
}
}
}
fn options(&self) -> Vec<crate::command::CmdOption> {
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 input_kind(&self) -> crate::command::InputKind {
if matches!(self.step, DrawOrderStep::SelectObjects | DrawOrderStep::PickReference { .. }) {
crate::command::InputKind::Point
} else {
crate::command::InputKind::SingleToken
}
}
fn is_selection_gathering(&self) -> bool {
matches!(self.step, DrawOrderStep::SelectObjects | DrawOrderStep::PickReference { .. })
}
fn on_selection_complete(&mut self, handles: Vec<codec::Handle>) -> crate::command::CmdResult {
if matches!(self.step, DrawOrderStep::PickReference { .. }) {
self.references = handles.into_iter().filter(|handle| !self.selected.contains(handle)).collect();
} else {
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 { above } => {
if self.references.is_empty() { return crate::command::CmdResult::Cancel; }
let option = if above { "ABOVE" } else { "UNDER" };
let references = self.references.iter().map(|handle| format!("{:x}", handle.value())).collect::<Vec<_>>().join(" ");
crate::command::CmdResult::Relaunch(format!("DRAWORDER {option} {references}"), std::mem::take(&mut self.selected))
}
}
}
fn on_text_input(&mut self, text: &str) -> Option<crate::command::CmdResult> {
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 { .. } => {
for text in t.split_whitespace() {
let hex = text.trim_start_matches("0x").trim_start_matches("0X");
if let Ok(value) = u64::from_str_radix(hex, 16) {
let handle = codec::Handle::new(value);
if !handle.is_null() && !self.selected.contains(&handle) && !self.references.contains(&handle) {
self.references.push(handle);
}
}
}
Some(crate::command::CmdResult::NeedPoint)
}
}
}
fn on_entity_pick(&mut self, handle: codec::Handle, _pt: glam::DVec3) -> crate::command::CmdResult {
if matches!(self.step, DrawOrderStep::PickReference { .. }) && !handle.is_null()
&& !self.selected.contains(&handle) && !self.references.contains(&handle) {
self.references.push(handle);
}
crate::command::CmdResult::NeedPoint
}
fn on_point(&mut self, _pt: glam::DVec3) -> crate::command::CmdResult {
crate::command::CmdResult::NeedPoint
}
}
/// Insert the selected block next to the extremal reference in effective draw
/// order. Renumber the ordered siblings to avoid ties or saturated sort keys.
fn assign_relative_group_keys(
document: &codec::CadDocument,
block: codec::Handle,
selected: &[codec::Handle],
references: &[codec::Handle],
above: bool,
) -> Option<Vec<(codec::Handle, u64)>> {
use codec::objects::ObjectType;
let overrides: std::collections::HashMap<_, _> = document.objects.values().find_map(|object| {
if let ObjectType::SortEntitiesTable(table) = object {
(table.block_owner_handle == block).then(|| table.entries().map(|entry| (entry.entity_handle, entry.sort_handle.value())).collect())
} else { None }
}).unwrap_or_default();
let mut ordered: Vec<_> = document.entities().filter(|entity| {
let owner = entity.common().owner_handle;
owner == block || owner.is_null()
}).map(|entity| entity.common().handle).collect();
ordered.sort_by_key(|handle| (overrides.get(handle).copied().unwrap_or(handle.value()), handle.value()));
let selected_set: std::collections::HashSet<_> = selected.iter().copied().collect();
let reference_set: std::collections::HashSet<_> = references.iter().copied().collect();
let moved: Vec<_> = ordered.iter().copied().filter(|handle| selected_set.contains(handle)).collect();
if moved.is_empty() { return None; }
ordered.retain(|handle| !selected_set.contains(handle));
let indices: Vec<_> = ordered.iter().enumerate().filter_map(|(index, handle)| reference_set.contains(handle).then_some(index)).collect();
let insertion = if above { indices.into_iter().max()? + 1 } else { indices.into_iter().min()? };
ordered.splice(insertion..insertion, moved);
Some(ordered.into_iter().enumerate().map(|(index, handle)| (handle, index as u64 + 1)).collect())
}
/// 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: &codec::CadDocument,
block_handle: codec::Handle,
group: &[codec::Handle],
floor: u64,
overrides: Option<&rustc_hash::FxHashMap<u64, u64>>,
locked_layers: &rustc_hash::FxHashSet<&str>,
) -> Vec<(codec::Handle, u64)> {
let n = group.len() as u64;
let mut out: Vec<(codec::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<u64> = group.iter().map(|h| h.value()).collect();
let has_locked = !locked_layers.is_empty();
let mut lifts: Vec<(codec::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 codec::objects::ObjectType;
use codec::EntityType;
fn fresh_app() -> OpenCADStudio {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app
}
#[test]
fn options_and_its_alias_dispatch_from_the_start_page() {
let mut app = fresh_app();
let i = app.active_tab;
assert!(app.dispatch_view("OPTIONS", i).is_some());
assert!(app.dispatch_view("OP", i).is_some());
assert!(crate::app::commands::start_allowed("OPTIONS"));
assert!(crate::app::commands::start_allowed("OP"));
}
#[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<u64, u64> = 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<u64, u64> = 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 = codec::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<u64, u64> = 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<u64, u64> = 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 = codec::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<u64, u64> = 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 codec::objects::{ObjectType, SortEntitiesTable};
let doc = &mut app.tabs[i].scene.document;
let nh = codec::Handle::new(doc.next_handle());
let mut table = SortEntitiesTable::for_block(block_handle);
table.handle = nh;
table.add_entry(h_line, codec::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<u64, u64> {
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 codec::objects::{ObjectType, SortEntitiesTable};
let doc = &mut app.tabs[i].scene.document;
let nh = codec::Handle::new(doc.next_handle());
let mut table = SortEntitiesTable::for_block(block_handle);
table.handle = nh;
table.add_entry(h_pinned, codec::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 relative_group_uses_extremal_references_and_preserves_internal_order() {
let mut app = fresh_app();
let i = app.active_tab;
let moved_first = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default()));
let reference_low = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default()));
let middle = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default()));
let reference_high = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default()));
let moved_last = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default()));
let block = app.tabs[i].scene.current_layout_block_handle_pub();
let selected = [moved_first, moved_last];
let references = [reference_low, reference_high];
let ordered = |above| {
let mut assignments = assign_relative_group_keys(
&app.tabs[i].scene.document,
block,
&selected,
&references,
above,
)
.expect("references belong to the active block");
assignments.sort_by_key(|(_, sort)| *sort);
assignments.into_iter().map(|(handle, _)| handle).collect::<Vec<_>>()
};
assert_eq!(
ordered(true),
vec![reference_low, middle, reference_high, moved_first, moved_last]
);
assert_eq!(
ordered(false),
vec![moved_first, moved_last, reference_low, middle, reference_high]
);
}
#[test]
fn relative_group_rejects_unusable_references_without_creating_a_table() {
let mut app = fresh_app();
let i = app.active_tab;
let moved = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default()));
app.tabs[i].scene.replace_selection(std::iter::once(moved).collect());
let _ = app.run_command_line("DRAWORDER ABOVE deadbeef");
assert!(!app.tabs[i].scene.document.objects.values().any(|object| {
matches!(object, ObjectType::SortEntitiesTable(_))
}));
}
#[test]
fn draw_order_table_reattaches_orphan_and_advances_object_handles() {
use codec::objects::SortEntitiesTable;
let mut app = fresh_app();
let i = app.active_tab;
let block = app.tabs[i].scene.current_layout_block_handle_pub();
let entity = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default()));
let orphan_handle = codec::Handle::new(0x10_0000);
let mut orphan = SortEntitiesTable::for_block(block);
orphan.handle = orphan_handle;
orphan.add_entry(entity, codec::Handle::new(7));
app.tabs[i]
.scene
.document
.objects
.insert(orphan_handle, ObjectType::SortEntitiesTable(orphan));
let resolved = ensure_draw_order_table(&mut app.tabs[i].scene.document, block);
assert_eq!(resolved, orphan_handle);
let dictionary_handle = app.tabs[i]
.scene
.document
.extension_dictionary_handle(block)
.expect("block extension dictionary");
let ObjectType::Dictionary(dictionary) = app.tabs[i]
.scene
.document
.objects
.get(&dictionary_handle)
.expect("dictionary object")
else {
panic!("extension object must be a dictionary");
};
assert_eq!(dictionary.get(SortEntitiesTable::DICTIONARY_KEY), Some(orphan_handle));
let ObjectType::SortEntitiesTable(table) = app.tabs[i]
.scene
.document
.objects
.get(&orphan_handle)
.expect("sort table")
else {
panic!("orphan must remain a sort table");
};
assert_eq!(table.owner_handle, dictionary_handle);
assert!(table.entries().any(|entry| {
entry.entity_handle == entity && entry.sort_handle.value() == 7
}));
assert!(app.tabs[i].scene.document.allocate_handle().value() > orphan_handle.value());
}
#[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.input_kind().wants_text(), 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 reference gathering (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.is_selection_gathering());
// 4. Shortcut 'U' -> Advances to reference gathering (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.is_selection_gathering());
}
#[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 a gathered viewport selection
let mut cmd = DrawOrderCommand::new(vec![h_hatch]);
let _ = cmd.on_text_input("A");
assert!(cmd.is_selection_gathering());
let _ = cmd.on_selection_complete(vec![h_hatch, h_ref]);
let pick_res = cmd.on_enter();
match pick_res {
crate::command::CmdResult::Relaunch(relaunch_cmd, handles) => {
assert_eq!(relaunch_cmd, format!("DRAWORDER ABOVE {: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 a gathered viewport selection
let mut cmd = DrawOrderCommand::new(vec![h_hatch]);
let _ = cmd.on_text_input("U");
assert!(cmd.is_selection_gathering());
let _ = cmd.on_selection_complete(vec![h_hatch, h_ref]);
let pick_res = cmd.on_enter();
match pick_res {
crate::command::CmdResult::Relaunch(relaunch_cmd, handles) => {
assert_eq!(relaunch_cmd, format!("DRAWORDER UNDER {: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);
assert!(matches!(typed_res, Some(crate::command::CmdResult::NeedPoint)));
match cmd.on_enter() {
crate::command::CmdResult::Relaunch(relaunch_cmd, handles) => {
assert_eq!(relaunch_cmd, format!("DRAWORDER ABOVE {: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.input_kind().wants_text());
// 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.input_kind().wants_text());
// 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);
}
#[test]
fn draworder_mode_aliases_route_directly() {
let mut app = fresh_app();
let i = app.active_tab;
let line = app.tabs[i]
.scene
.add_entity_clone(EntityType::Line(Default::default()));
let hatch = app.tabs[i]
.scene
.add_entity_clone(EntityType::Hatch(Default::default()));
app.tabs[i]
.scene
.replace_selection(std::iter::once(hatch).collect());
let _ = app.run_command_line("DRAWORDER_FRONT");
let entries = effective_sort_map(&app);
assert!(
entries.get(&hatch.value()).copied().unwrap_or(hatch.value())
> entries.get(&line.value()).copied().unwrap_or(line.value())
);
app.tabs[i]
.scene
.replace_selection(std::iter::once(hatch).collect());
let _ = app.run_command_line("DRAWORDER_ABOVE");
assert!(app.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|command| command.name() == "DRAWORDER" && command.is_selection_gathering()));
}
}