diff --git a/src/app/commands/layerprops.rs b/src/app/commands/layerprops.rs index 7d72f79c5..7927567a7 100644 --- a/src/app/commands/layerprops.rs +++ b/src/app/commands/layerprops.rs @@ -760,83 +760,21 @@ impl OpenCADStudio { } } - // -VIEW: the command-line form. Its presets set the standard - // orthographic and isometric views; the rest are VIEW's verbs. + // -VIEW: the command-line form of VIEW (modules/view/view_cmd.rs). + // The command gathers the input; `-VIEW >…` carries out what it + // gathered. A whole line (`-VIEW _O _FRONT`) feeds its words to + // the command one by one, as typed. "-VIEW" => { - use crate::command::KeywordCommand; - let c = KeywordCommand::new( - "-VIEW", - "Enter an option [?/Delete/Orthographic/Restore/Save/sEttings/Window]:", - vec![ - ("?", "?", None), - ("Delete", "DELETE", Some("Enter view name(s) to delete:")), - ("Orthographic", "ORTHOGRAPHIC", None), - ("Restore", "RESTORE", Some("Enter view name to restore:")), - ("Save", "SAVE", Some("Enter view name to save:")), - ], - ) - .with_hidden(&["TOP", "BOTTOM", "FRONT", "BACK", "LEFT", "RIGHT", "SWISO", "SEISO", "NEISO", "NWISO"]); + use crate::command::CadCommand; + let c = crate::modules::view::view_cmd::DashViewCommand::new(self.dash_view_names(i)); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } + cmd if cmd.starts_with("-VIEW >") => { + return Some(self.run_dash_view(&cmd["-VIEW >".len()..], i)); + } cmd if cmd.starts_with("-VIEW ") => { - let words: Vec = cmd["-VIEW ".len()..] - .split_whitespace() - .map(|word| word.trim_start_matches('_').to_ascii_uppercase()) - .collect(); - let first = words.first().map(String::as_str).unwrap_or(""); - let preset = |word: &str| -> Option<&'static str> { - ["TOP", "BOTTOM", "FRONT", "BACK", "LEFT", "RIGHT", "SWISO", "SEISO", "NEISO", "NWISO"] - .into_iter() - .find(|name| name.eq_ignore_ascii_case(word)) - }; - // The Orthographic face: Top/Bottom/Front/BAck/Left/Right. - let face = |word: &str| -> Option<&'static str> { - match word { - "BA" | "BAC" | "BACK" => Some("BACK"), - _ => ["TOP", "BOTTOM", "FRONT", "LEFT", "RIGHT"] - .into_iter() - .find(|name| !word.is_empty() && name.starts_with(word)), - } - }; - let orthographic = !first.is_empty() && "ORTHOGRAPHIC".starts_with(first); - let view = if orthographic { - match words.get(1) { - Some(word) => face(word), - None => { - use crate::command::KeywordCommand; - let c = KeywordCommand::new( - "-VIEW ORTHOGRAPHIC", - "Enter an option [Top/Bottom/Front/BAck/Left/Right]:", - vec![ - ("Top", "TOP", None), - ("Bottom", "BOTTOM", None), - ("Front", "FRONT", None), - ("BAck", "BACK", None), - ("Left", "LEFT", None), - ("Right", "RIGHT", None), - ], - ) - .with_default("TOP"); - self.command_line.push_info(&c.prompt()); - self.tabs[i].active_cmd = Some(Box::new(c)); - return Some(self.finish_dispatch(cmd)); - } - } - } else { - preset(first) - }; - if let Some(view) = view { - self.command_line.push_output(crate::t!("Regenerating model.").as_ref()); - return self.dispatch_layerprops(&format!("VIEW {view}"), i); - } - if orthographic { - self.command_line.push_error(crate::t!("Invalid option keyword.").as_ref()); - return Some(self.finish_dispatch(cmd)); - } - // View names keep their case. - let rest = cmd["-VIEW ".len()..].split_whitespace().skip(1).collect::>().join(" "); - return self.dispatch_layerprops(format!("VIEW {first} {rest}").trim_end(), i); + return Some(self.run_command_line(cmd)); } "VIEW" => { use crate::command::KeywordCommand; @@ -936,45 +874,14 @@ impl OpenCADStudio { } } } - // Standard orientation presets — snap the camera to a world - // axis view (these names take precedence over a same-named - // saved view, matching the standard orientation behaviour). - "TOP" | "FRONT" | "BACK" | "LEFT" | "RIGHT" | "BOTTOM" => { - use crate::scene::pipeline::viewcube::{ - FACE_BACK, FACE_BOTTOM, FACE_FRONT, FACE_LEFT, FACE_RIGHT, FACE_TOP, - }; - let face = match sub.as_str() { - "TOP" => FACE_TOP, - "BOTTOM" => FACE_BOTTOM, - "FRONT" => FACE_FRONT, - "BACK" => FACE_BACK, - "RIGHT" => FACE_RIGHT, - _ => FACE_LEFT, - }; - return Some(Task::done(Message::ViewCubeSnapWorld( - crate::scene::CubeRegion::Face(face), - ))); + // Standard orientation presets: the world views -VIEW sets, + // with the orthographic UCS while UCSORTHO is on. These + // names take precedence over a same-named saved view. + "TOP" | "FRONT" | "BACK" | "LEFT" | "RIGHT" | "BOTTOM" | "SWISO" | "SEISO" | "NEISO" | "NWISO" => { + return Some(self.apply_view_preset(&sub)); } - // Isometric presets snap to the matching ViewCube corner; // ISO alone is the south-west one. - "ISO" | "ISOMETRIC" | "SWISO" | "SEISO" | "NEISO" | "NWISO" => { - let (x, y) = match sub.as_str() { - "SEISO" => (1.0, -1.0), - "NEISO" => (1.0, 1.0), - "NWISO" => (-1.0, 1.0), - _ => (-1.0, -1.0), - }; - let want = glam::Vec3::new(x, y, 1.0).normalize(); - let corner = (18..26) - .map(crate::scene::CubeRegion::Corner) - .max_by(|a, b| { - a.snap_direction() - .dot(want) - .total_cmp(&b.snap_direction().dot(want)) - }) - .expect("the cube has corners"); - return Some(Task::done(Message::ViewCubeSnap(corner))); - } + "ISO" | "ISOMETRIC" => return Some(self.apply_view_preset("SWISO")), // VIEW shortcut for restore _ => { let name = sub.clone(); diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 4131211e9..87c75a24e 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -28,6 +28,7 @@ mod blockvars; mod plotvars; mod styleprops; mod view; +mod view_presets; // `DrawOrderRefCommand` lives in the `view` family file but is referenced by // path (`commands::DrawOrderRefCommand`) from `update.rs`, so re-export it at diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index d8faa16c4..60e2cad4d 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1120,6 +1120,8 @@ impl OpenCADStudio { | "UOSNAP" | "FIELDDISPLAY" | "SURFACEASSOCIATIVITY" + | "UCSORTHO" + | "UCSVIEW" | "PDFIMPORTMODE" | "PDFIMPORTFILTER" | "PDFIMPORTLAYERS" @@ -1328,6 +1330,32 @@ impl OpenCADStudio { } return Some(self.finish_dispatch(cmd)); } + if matches!(name.as_str(), "UCSORTHO" | "UCSVIEW") { + use crate::modules::view::view_cmd; + let current = i16::from(if name == "UCSORTHO" { view_cmd::ucs_ortho() } else { view_cmd::ucs_view() }); + match value.as_deref().map(|v| v.trim().parse::().ok().filter(|v| (0..=1).contains(v))) { + Some(Some(mode)) => { + if current != mode { + if name == "UCSORTHO" { + view_cmd::set_ucs_ortho(mode == 1); + } else { + view_cmd::set_ucs_view(mode == 1); + } + self.save_config(); + } + } + Some(None) => { + self.command_line.push_error(crate::t!("Requires 0 or 1 only.").as_ref()); + self.command_line.push_output(&format!("Enter new value for {name} <{current}>:")); + self.pending_setvar = Some(name.clone()); + } + None => { + self.command_line.push_output(&format!("Enter new value for {name} <{current}>:")); + self.pending_setvar = Some(name.clone()); + } + } + return Some(self.finish_dispatch(cmd)); + } if name == "SURFACEASSOCIATIVITY" { use crate::scene::model::sweep_model; let current = i16::from(sweep_model::surface_associativity()); diff --git a/src/app/commands/view_presets.rs b/src/app/commands/view_presets.rs new file mode 100644 index 000000000..017cf2d87 --- /dev/null +++ b/src/app/commands/view_presets.rs @@ -0,0 +1,345 @@ +//! What -VIEW carries out: the view presets (with the orthographic UCS of +//! UCSORTHO), named views saved with the UCS of UCSVIEW, and the per-view +//! settings of its sEttings option. + +use super::super::{Message, OpenCADStudio}; +use crate::command::CadCommand; +use crate::modules::view::view_cmd::{self, DashViewCommand, ViewInfo, ViewNames}; +use codec::objects::ObjectType; +use codec::types::{Handle, Vector3}; +use iced::Task; + +/// The xrecord in a view's extension dictionary holding its category and +/// layer snapshot, as the reference keeps them. +const VIEW_INFO: &str = "ADSK_XREC_VTRVIEWINFO"; + +/// The layer state a view's layer snapshot is stored under. +fn snapshot_name(view: &str) -> String { + format!("ACAD_VIEWS_{view}") +} + +impl OpenCADStudio { + /// Names -VIEW checks its input against, taken from the drawing. + pub(super) fn dash_view_names(&self, i: usize) -> ViewNames { + let document = &self.tabs[i].scene.document; + let visual_styles: Vec<(String, Handle)> = match document + .objects + .get(&document.header.acad_visualstyle_dict_handle) + { + Some(ObjectType::Dictionary(dictionary)) => dictionary.entries.clone(), + _ => Vec::new(), + }; + let live_sections: Vec<(String, Handle)> = document + .entities() + .filter_map(|entity| match entity { + codec::EntityType::Extended(extended) => match &extended.data { + codec::entities::ExtendedEntityData::SectionObject(section) => { + Some((section.name.clone(), extended.common.handle)) + } + _ => None, + }, + _ => None, + }) + .collect(); + let name_of = |list: &[(String, Handle)], handle: Handle| { + (!handle.is_null()) + .then(|| list.iter().find(|(_, h)| *h == handle).map(|(name, _)| name.clone())) + .flatten() + }; + let views = document + .views + .iter() + .map(|view| ViewInfo { + name: view.name.clone(), + category: (!view.handle.is_null()) + .then(|| document.xrecord(view.handle, VIEW_INFO)) + .flatten() + .and_then(|record| record.get_string(300)) + .unwrap_or_default() + .to_string(), + visual_style: name_of(&visual_styles, view.visual_style_handle), + live_section: name_of(&live_sections, view.live_section_handle), + }) + .collect(); + ViewNames { + views, + visual_styles: visual_styles.into_iter().map(|(name, _)| name).collect(), + live_sections: live_sections.into_iter().map(|(name, _)| name).collect(), + } + } + + /// Set a standard view: the world orthographic or isometric view of + /// `preset`. An orthographic one also sets its orthographic UCS while + /// UCSORTHO is on; asking for the view shown already keeps it. + pub(in crate::app) fn apply_view_preset(&mut self, preset: &str) -> Task { + use crate::scene::pipeline::viewcube::{ + FACE_BACK, FACE_BOTTOM, FACE_FRONT, FACE_LEFT, FACE_RIGHT, FACE_TOP, + }; + use crate::scene::CubeRegion; + let i = self.active_tab; + let region = match preset { + "TOP" => CubeRegion::Face(FACE_TOP), + "BOTTOM" => CubeRegion::Face(FACE_BOTTOM), + "FRONT" => CubeRegion::Face(FACE_FRONT), + "BACK" => CubeRegion::Face(FACE_BACK), + "LEFT" => CubeRegion::Face(FACE_LEFT), + "RIGHT" => CubeRegion::Face(FACE_RIGHT), + _ => { + let (x, y) = match preset { + "SEISO" => (1.0, -1.0), + "NEISO" => (1.0, 1.0), + "NWISO" => (-1.0, 1.0), + _ => (-1.0, -1.0), + }; + let want = glam::Vec3::new(x, y, 1.0).normalize(); + (18..26) + .map(CubeRegion::Corner) + .max_by(|a, b| a.snap_direction().dot(want).total_cmp(&b.snap_direction().dot(want))) + .expect("the cube has corners") + } + }; + if view_cmd::ucs_ortho() && self.tabs[i].active_block_edit.is_none() { + if let Some((name, x, y, ortho)) = view_cmd::orthographic_ucs(preset) { + // The top view's UCS is the world one. + self.tabs[i].active_ucs = (ortho != 1).then(|| { + let mut ucs = codec::tables::Ucs::new(name); + ucs.origin = Vector3::ZERO; + ucs.x_axis = Vector3::new(x[0], x[1], x[2]); + ucs.y_axis = Vector3::new(y[0], y[1], y[2]); + ucs.ortho_type = ortho; + ucs + }); + self.commit_active_ucs_change(i, "-VIEW"); + } + } + self.command_line.push_output(crate::t!("Regenerating model.").as_ref()); + self.snap_view_preset(region) + } + + /// Carry out what -VIEW gathered (`-VIEW >VERB args`). + pub(super) fn run_dash_view(&mut self, rest: &str, i: usize) -> Task { + let (verb, arg) = rest.split_once(' ').unwrap_or((rest, "")); + let mut settings_again = false; + match verb { + "END" => { + if !arg.is_empty() { + self.command_line.push_output(arg); + } + } + "PRESET" => return self.apply_view_preset(arg), + "DELETE" => { + let doomed: Vec = view_cmd::matching( + self.tabs[i].scene.document.views.iter().map(|view| view.name.as_str()), + arg, + ) + .into_iter() + .map(str::to_string) + .collect(); + if !doomed.is_empty() { + self.push_undo_snapshot(i, "-VIEW"); + for name in &doomed { + self.tabs[i].scene.document.views.remove(name); + self.tabs[i].scene.document.delete_layer_state(&snapshot_name(name)); + } + self.tabs[i].dirty = true; + } + } + "RESTORE" => { + let Some(view) = self.tabs[i].scene.document.views.get(arg).cloned() else { + return Task::none(); + }; + let before = self.tabs[i].scene.active_gaze_dir(); + self.tabs[i].scene.restore_named_view(&view); + if view.ucs_associated && self.tabs[i].active_block_edit.is_none() { + let world = view.ucs_x_axis == Vector3::new(1.0, 0.0, 0.0) + && view.ucs_y_axis == Vector3::new(0.0, 1.0, 0.0) + && view.ucs_origin == Vector3::ZERO; + self.tabs[i].active_ucs = (!world).then(|| { + let name = view_cmd::PRESETS + .iter() + .filter_map(|preset| view_cmd::orthographic_ucs(preset)) + .find(|(_, _, _, ortho)| *ortho == view.ucs_ortho_type) + .map_or("*ACTIVE*", |(name, ..)| name); + let mut ucs = codec::tables::Ucs::new(name); + ucs.origin = view.ucs_origin; + ucs.x_axis = view.ucs_x_axis; + ucs.y_axis = view.ucs_y_axis; + ucs.elevation = view.ucs_elevation; + ucs.ortho_type = view.ucs_ortho_type; + ucs + }); + self.commit_active_ucs_change(i, "-VIEW"); + } + if self.tabs[i].scene.active_gaze_dir().dot(before) < 0.9999 { + self.command_line.push_output(crate::t!("Regenerating model.").as_ref()); + } + } + "SAVE" => { + let mut view = self.tabs[i].scene.current_as_named_view(arg); + if view_cmd::ucs_view() { + self.command_line.push_output("UCSVIEW = 1 UCS will be saved with view"); + self.store_ucs_in_view(i, &mut view); + } + self.save_named_view(i, view); + } + "WINDOW" => { + let parts: Vec<&str> = arg.split('\u{1f}').collect(); + let corner = |s: &str| { + let (x, y) = s.split_once(',')?; + Some((x.trim().parse::().ok()?, y.trim().parse::().ok()?)) + }; + match (parts.first(), parts.get(1).and_then(|s| corner(s)), parts.get(2).and_then(|s| corner(s))) { + (Some(name), Some(a), Some(b)) if a.0 != b.0 && a.1 != b.1 => { + // A plan view of the window between the corners; the + // reference keeps no UCS with a window view. + let mut view = self.tabs[i].scene.current_as_named_view(name); + view.target = Vector3 { x: (a.0 + b.0) / 2.0, y: (a.1 + b.1) / 2.0, z: 0.0 }; + view.direction = Vector3 { x: 0.0, y: 0.0, z: 1.0 }; + view.width = (b.0 - a.0).abs(); + view.height = (b.1 - a.1).abs(); + self.save_named_view(i, view); + } + _ => self.command_line.push_output("Invalid window specification."), + } + } + "SETTINGS" => settings_again = true, + "UCSVIEW" => { + view_cmd::set_ucs_view(arg == "1"); + self.save_config(); + settings_again = true; + } + "SET" => { + let parts: Vec<&str> = arg.split('\u{1f}').collect(); + if let [setting, view, value] = parts[..] { + self.apply_view_setting(i, setting, view, value); + } + settings_again = true; + } + _ => {} + } + if settings_again { + let command = DashViewCommand::settings(self.dash_view_names(i)); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + self.finish_dispatch("-VIEW") + } + + /// Keep the current UCS with a view being saved (UCSVIEW). + fn store_ucs_in_view(&self, i: usize, view: &mut codec::tables::View) { + view.ucs_associated = true; + match &self.tabs[i].active_ucs { + Some(ucs) => { + view.ucs_origin = ucs.origin; + view.ucs_x_axis = ucs.x_axis; + view.ucs_y_axis = ucs.y_axis; + view.ucs_elevation = ucs.elevation; + view.ucs_ortho_type = ucs.ortho_type; + view.named_ucs_handle = ucs.named_ucs_handle; + } + None => { + view.ucs_origin = Vector3::ZERO; + view.ucs_x_axis = Vector3::new(1.0, 0.0, 0.0); + view.ucs_y_axis = Vector3::new(0.0, 1.0, 0.0); + view.ucs_elevation = 0.0; + view.ucs_ortho_type = 0; + } + } + } + + /// Add or replace a named view, keeping the record (and so its settings) + /// of a view saved again under the same name. + fn save_named_view(&mut self, i: usize, mut view: codec::tables::View) { + self.push_undo_snapshot(i, "-VIEW"); + let document = &mut self.tabs[i].scene.document; + match document.views.get(&view.name).cloned() { + Some(old) => { + view.name = old.name; + view.handle = old.handle; + view.visual_style_handle = old.visual_style_handle; + view.live_section_handle = old.live_section_handle; + view.background_handle = old.background_handle; + } + None => view.handle = document.allocate_handle(), + } + document.views.add_or_replace(view); + self.tabs[i].dirty = true; + } + + /// One setting of -VIEW's sEttings option on the named view. + fn apply_view_setting(&mut self, i: usize, setting: &str, name: &str, value: &str) { + self.push_undo_snapshot(i, "-VIEW"); + let document = &mut self.tabs[i].scene.document; + let Some(mut view) = document.views.get(name).cloned() else { + return; + }; + if view.handle.is_null() { + view.handle = document.allocate_handle(); + } + let find = |list: Vec<(String, Handle)>| { + list.into_iter().find(|(entry, _)| entry.eq_ignore_ascii_case(value)).map_or(Handle::NULL, |(_, h)| h) + }; + match setting { + "BACKGROUND" => view.background_handle = Handle::NULL, + "VISUALSTYLE" => { + let styles = match document.objects.get(&document.header.acad_visualstyle_dict_handle) { + Some(ObjectType::Dictionary(dictionary)) => dictionary.entries.clone(), + _ => Vec::new(), + }; + view.visual_style_handle = find(styles); + } + "SECTION" => { + let sections: Vec<(String, Handle)> = document + .entities() + .filter_map(|entity| match entity { + codec::EntityType::Extended(extended) => match &extended.data { + codec::entities::ExtendedEntityData::SectionObject(section) => { + Some((section.name.clone(), extended.common.handle)) + } + _ => None, + }, + _ => None, + }) + .collect(); + view.live_section_handle = find(sections); + } + "CATEGORIZE" | "LAYER" => { + let snapshot = snapshot_name(&view.name); + let layer_name = match (setting, value) { + ("LAYER", "SAVE") => { + document.capture_layer_state(snapshot.clone(), ""); + Some(snapshot) + } + ("LAYER", _) => { + document.delete_layer_state(&snapshot); + Some(String::new()) + } + _ => None, + }; + let record = document.ensure_xrecord(view.handle, VIEW_INFO); + if let Some(ObjectType::XRecord(record)) = document.objects.get_mut(&record) { + let text = |record: &codec::objects::XRecord, code: i32| { + record.get_string(code).unwrap_or_default().to_string() + }; + let category = if setting == "CATEGORIZE" { value.to_string() } else { text(record, 300) }; + let layers = layer_name.unwrap_or_else(|| text(record, 302)); + let mut rest: Vec<_> = + record.entries.iter().filter(|entry| !matches!(entry.code, 300 | 302)).cloned().collect(); + if rest.is_empty() { + rest = vec![ + codec::objects::XRecordEntry::bool(293, false), + codec::objects::XRecordEntry::bool(294, false), + ]; + } + record.entries.clear(); + record.add_string(300, category); + record.add_string(302, layers); + record.entries.extend(rest); + } + } + _ => {} + } + document.views.add_or_replace(view); + self.tabs[i].dirty = true; + } +} diff --git a/src/app/settings.rs b/src/app/settings.rs index 87ded73a8..eb60fd591 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -424,6 +424,10 @@ pub struct UserSettings { pub field_display: bool, /// SURFACEASSOCIATIVITY: new surfaces stay associative to their sources. pub surface_associativity: bool, + /// UCSORTHO: an orthographic view preset also sets its orthographic UCS. + pub ucs_ortho: bool, + /// UCSVIEW: a saved named view keeps the current UCS. + pub ucs_view: bool, /// Nested-copy symbol handling: false inserts, true binds. pub ncopy_bind: bool, /// Last Options page; unknown saved names fall back without rejecting the config. @@ -838,6 +842,8 @@ impl Default for UserSettings { attdef_on_screen: true, field_display: true, surface_associativity: true, + ucs_ortho: true, + ucs_view: true, ncopy_bind: false, cursor_type: CursorType::Crosshair, crosshair_color: None, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 6e0c8f294..bb3279db5 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -681,6 +681,8 @@ impl OpenCADStudio { attdef_on_screen: crate::modules::draw::draw::attdef::session().on_screen, field_display: crate::entities::field::display(), surface_associativity: crate::scene::model::sweep_model::surface_associativity(), + ucs_ortho: crate::modules::view::view_cmd::ucs_ortho(), + ucs_view: crate::modules::view::view_cmd::ucs_view(), ncopy_bind: self.ncopy_bind, cursor_type: self.cursor_type, crosshair_color: self.crosshair_color, @@ -793,6 +795,8 @@ impl OpenCADStudio { crate::modules::draw::draw::attdef::session().on_screen = s.attdef_on_screen; crate::entities::field::set_display(s.field_display); crate::scene::model::sweep_model::set_surface_associativity(s.surface_associativity); + crate::modules::view::view_cmd::set_ucs_ortho(s.ucs_ortho); + crate::modules::view::view_cmd::set_ucs_view(s.ucs_view); self.ncopy_bind = s.ncopy_bind; self.cursor_type = s.cursor_type; self.crosshair_color = s.crosshair_color; diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 01ca6a0c3..11fd98a58 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -5912,15 +5912,25 @@ properties={:.1}ms picked={}", } fn snap_view_region(&mut self, region: CubeRegion, r_ucs: glam::Mat4) -> Task { + self.snap_view_region_as(region, r_ucs, true) + } + + /// A standard view preset (-VIEW): the world view itself, kept when it + /// is shown already, with no cube message. + pub(in crate::app) fn snap_view_preset(&mut self, region: CubeRegion) -> Task { + self.snap_view_region_as(region, glam::Mat4::IDENTITY, false) + } + + fn snap_view_region_as(&mut self, region: CubeRegion, r_ucs: glam::Mat4, cube: bool) -> Task { let i = self.active_tab; self.clear_navigation_hover(i); self.tabs[i].scene.remember_current_view(); let mut region = region; - // "Already there → flip to opposite" check: compare the - // current gaze direction with the region's target gaze. + // "Already there → flip to opposite" check for a cube click: compare + // the current gaze direction with the region's target gaze. let target_dir = r_ucs.transform_vector3(region.snap_direction()); let cur_dir = self.tabs[i].scene.active_gaze_dir(); - if cur_dir.dot(target_dir) > 0.9999 { + if cube && cur_dir.dot(target_dir) > 0.9999 { region = region.opposite(); } let eye_dir = r_ucs.transform_vector3(region.snap_direction()); @@ -5950,8 +5960,10 @@ properties={:.1}ms picked={}", } } self.tabs[i].scene.camera_generation += 1; - self.command_line - .push_output(crate::tf!("View: {}", crate::t!(region.label())).as_ref()); + if cube { + self.command_line + .push_output(crate::tf!("View: {}", crate::t!(region.label())).as_ref()); + } Task::none() } diff --git a/src/command.rs b/src/command.rs index 2b250a10b..875c6d9ba 100644 --- a/src/command.rs +++ b/src/command.rs @@ -642,9 +642,6 @@ pub struct KeywordCommand { /// Keyword a bare Enter dispatches at the verb step (`` prompts, /// e.g. PLAN's ``). `None` = Enter cancels, as before. default: Option<&'static str>, - /// Keywords taken at the verb step without being offered in the prompt - /// (-VIEW's view presets). - hidden: &'static [&'static str], } impl KeywordCommand { @@ -654,12 +651,6 @@ impl KeywordCommand { self } - /// Keywords accepted at the verb step but not listed in the prompt. - pub fn with_hidden(mut self, keywords: &'static [&'static str]) -> Self { - self.hidden = keywords; - self - } - pub fn new( name: &'static str, prompt: &'static str, @@ -671,7 +662,6 @@ impl KeywordCommand { options, pending: None, default: None, - hidden: &[], } } } @@ -746,9 +736,6 @@ impl CadCommand for KeywordCommand { None => { // A leading underscore marks the English keyword, as in scripts. let t = t.strip_prefix('_').unwrap_or(t); - if let Some(keyword) = self.hidden.iter().find(|keyword| keyword.eq_ignore_ascii_case(t)) { - return Some(CmdResult::Dispatch(format!("{} {keyword}", self.name))); - } let Some((_, keyword, value_prompt)) = match_cmd_option(&self.options, t) else { // Unknown verb — keep prompting rather than dispatch garbage. return Some(CmdResult::NeedPoint); diff --git a/src/modules/view/mod.rs b/src/modules/view/mod.rs index e11bbd383..4981900d9 100644 --- a/src/modules/view/mod.rs +++ b/src/modules/view/mod.rs @@ -18,6 +18,7 @@ mod tile_vert; pub mod visual_style; mod tool_palettes; pub mod ucs_cmd; +pub mod view_cmd; mod ucs_icon; mod view_front; mod view_iso; diff --git a/src/modules/view/view_cmd.rs b/src/modules/view/view_cmd.rs new file mode 100644 index 000000000..c29d9164b --- /dev/null +++ b/src/modules/view/view_cmd.rs @@ -0,0 +1,527 @@ +//! -VIEW: the command-line form of VIEW. +//! +//! The prompts follow the reference command line word for word. The command +//! only gathers input and checks the names it is given against the drawing +//! (a snapshot taken when it starts); whatever changes the drawing or the +//! camera is handed to the inline `-VIEW >…` handler through +//! [`CmdResult::Dispatch`], which re-enters the settings loop where the +//! reference does. + +use crate::command::{CadCommand, CmdResult}; +use glam::DVec3; +use std::sync::atomic::{AtomicBool, Ordering}; + +static UCS_ORTHO: AtomicBool = AtomicBool::new(true); +static UCS_VIEW: AtomicBool = AtomicBool::new(true); + +/// UCSORTHO: an orthographic view preset also sets the matching orthographic UCS. +pub fn ucs_ortho() -> bool { + UCS_ORTHO.load(Ordering::Relaxed) +} + +pub fn set_ucs_ortho(on: bool) { + UCS_ORTHO.store(on, Ordering::Relaxed); +} + +/// UCSVIEW: a saved named view keeps the current UCS. +pub fn ucs_view() -> bool { + UCS_VIEW.load(Ordering::Relaxed) +} + +pub fn set_ucs_view(on: bool) { + UCS_VIEW.store(on, Ordering::Relaxed); +} + +/// The standard orthographic and isometric view presets. +pub const PRESETS: [&str; 10] = + ["TOP", "BOTTOM", "FRONT", "BACK", "LEFT", "RIGHT", "SWISO", "SEISO", "NEISO", "NWISO"]; + +/// The orthographic UCS an orthographic preset sets while UCSORTHO is on: +/// its name, X and Y axes and ORTHOGRAPHIC type, as the reference sets them. +pub fn orthographic_ucs(preset: &str) -> Option<(&'static str, [f64; 3], [f64; 3], i16)> { + Some(match preset { + "TOP" => ("*TOP*", [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1), + "BOTTOM" => ("*BOTTOM*", [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], 2), + "FRONT" => ("*FRONT*", [1.0, 0.0, 0.0], [0.0, 0.0, 1.0], 3), + "BACK" => ("*BACK*", [-1.0, 0.0, 0.0], [0.0, 0.0, 1.0], 4), + "LEFT" => ("*LEFT*", [0.0, -1.0, 0.0], [0.0, 0.0, 1.0], 5), + "RIGHT" => ("*RIGHT*", [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], 6), + _ => return None, + }) +} + +/// One named view, as -VIEW's settings need to know it. +#[derive(Clone, Default)] +pub struct ViewInfo { + pub name: String, + pub category: String, + pub visual_style: Option, + pub live_section: Option, +} + +/// What -VIEW checks typed names against. +#[derive(Clone, Default)] +pub struct ViewNames { + pub views: Vec, + pub visual_styles: Vec, + pub live_sections: Vec, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Setting { + Background, + Categorize, + LayerSnapshot, + LiveSection, + VisualStyle, +} + +impl Setting { + /// The word the inline handler takes. + pub fn verb(self) -> &'static str { + match self { + Setting::Background => "BACKGROUND", + Setting::Categorize => "CATEGORIZE", + Setting::LayerSnapshot => "LAYER", + Setting::LiveSection => "SECTION", + Setting::VisualStyle => "VISUALSTYLE", + } + } + + fn view_prompt(self) -> &'static str { + match self { + Setting::Background => "Enter view name to edit Background or [?]:", + Setting::Categorize => "Enter view name to Categorize or [?]:", + Setting::LayerSnapshot => "Enter view name to edit Layer snapshot or [?]:", + Setting::LiveSection => "Enter view name to edit live section or [?]:", + Setting::VisualStyle => "Enter view name to edit visual style or [?]:", + } + } +} + +#[derive(Clone, PartialEq, Debug)] +enum Step { + Main, + Orthographic, + List, + Delete, + Restore, + Save, + WindowName, + WindowFirst(String), + WindowSecond(String, DVec3), + Settings, + SettingView(Setting), + SettingViewList(Setting), + SettingValue(Setting, String), + SettingValueList(Setting, String), + Ucs, +} + +pub struct DashViewCommand { + names: ViewNames, + step: Step, +} + +/// Typed `input` matched against the options of a prompt. An option's +/// keyword is its word holding capitals (`live Section` → `Section`); the +/// capitals alone are its abbreviation (`sEttings` → `E`), and any start of +/// the keyword that reaches its last capital is taken too (`SE`, `SETTINGS`). +/// A leading `_` is the international form scripts use. +fn keyword(input: &str, options: &[&'static str]) -> Option<&'static str> { + let input = input.trim().trim_start_matches('_').to_ascii_uppercase(); + if input.is_empty() { + return None; + } + options.iter().copied().find(|option| { + if *option == "?" { + return input == "?"; + } + let word = option + .split(' ') + .find(|word| word.chars().any(|c| c.is_ascii_uppercase())) + .unwrap_or(option); + let capitals: String = word.chars().filter(char::is_ascii_uppercase).collect(); + let reach = word.rfind(|c: char| c.is_ascii_uppercase()).map_or(1, |at| at + 1); + input == capitals || (word.to_ascii_uppercase().starts_with(&input) && input.len() >= reach) + }) +} + +/// The names in `names` that one of the comma-separated wildcard patterns matches. +pub fn matching<'a>(names: impl IntoIterator, patterns: &str) -> Vec<&'a str> { + let patterns: Vec<&str> = patterns.split(',').map(str::trim).filter(|p| !p.is_empty()).collect(); + names + .into_iter() + .filter(|name| patterns.iter().any(|pattern| crate::io::xref_model::wildcard_match(name, pattern))) + .collect() +} + +/// The reference's listing of saved views: `"NAME"` padded to 35 columns +/// followed by the space letter. +pub fn view_listing(views: &[(String, bool)]) -> String { + let mut out = String::from("Saved views:"); + if views.is_empty() { + out.push_str("\n No matching views found."); + return out; + } + out.push_str("\nView name Space"); + for (name, paper) in views { + out.push_str(&format!("\n{:<35}{}", format!("\"{name}\""), if *paper { "P" } else { "M" })); + } + out +} + +impl DashViewCommand { + pub fn new(names: ViewNames) -> Self { + Self { names, step: Step::Main } + } + + /// The command back at its settings prompt, after a setting was applied. + pub fn settings(names: ViewNames) -> Self { + Self { names, step: Step::Settings } + } + + fn view(&self, name: &str) -> Option<&ViewInfo> { + self.names.views.iter().find(|view| view.name.eq_ignore_ascii_case(name)) + } + + fn end(message: &str) -> CmdResult { + CmdResult::Dispatch(format!("-VIEW >END {message}")) + } + + fn list_views(&self, pattern: &str) -> String { + let pattern = if pattern.trim().is_empty() { "*" } else { pattern }; + let names: Vec<&str> = self.names.views.iter().map(|view| view.name.as_str()).collect(); + let found: Vec<(String, bool)> = + matching(names, pattern).into_iter().map(|name| (name.to_string(), false)).collect(); + view_listing(&found) + } + + fn value_prompt(&self, setting: Setting, view: &str) -> String { + let info = self.view(view).cloned().unwrap_or_default(); + match setting { + Setting::Background => "Specify background type [Color/Gradient/Image/None]:".into(), + Setting::Categorize => { + format!("Enter category name or * for none, or [?]: <\"{}\">", info.category) + } + Setting::LayerSnapshot => "Enter an option [Save/Delete] :".into(), + Setting::LiveSection => format!( + "Enter live section name or * for none, or [?]: <{}>:", + info.live_section.as_deref().unwrap_or("*") + ), + Setting::VisualStyle => format!( + "Enter visual style name or * for none, or [?]: <{}>:", + info.visual_style.as_deref().unwrap_or("2D Wireframe") + ), + } + } + + /// Hand a setting to the inline handler, which applies it and starts the + /// settings prompt again. + fn apply(setting: Setting, view: &str, value: &str) -> CmdResult { + CmdResult::Dispatch(format!("-VIEW >SET {}\u{1f}{view}\u{1f}{value}", setting.verb())) + } + + fn on_setting_value(&mut self, setting: Setting, view: String, text: &str) -> CmdResult { + let t = text.trim(); + match setting { + Setting::Background => match keyword(t, &["Color", "Gradient", "Image", "None"]) { + // The other background types are chosen in their own dialogs. + Some("None") => Self::apply(setting, &view, "NONE"), + Some(_) => CmdResult::Dispatch("-VIEW >SETTINGS".into()), + None => { + self.step = Step::SettingValue(setting, view); + CmdResult::ReportError("Invalid option keyword.".into()) + } + }, + Setting::LayerSnapshot => match keyword(t, &["Save", "Delete"]) { + Some("Save") => Self::apply(setting, &view, "SAVE"), + Some(_) => Self::apply(setting, &view, "DELETE"), + None => { + self.step = Step::SettingValue(setting, view); + CmdResult::ReportError("Invalid option keyword.".into()) + } + }, + Setting::Categorize => { + if t == "?" { + self.step = Step::SettingValueList(setting, view); + return CmdResult::NeedPoint; + } + let value = if t == "*" { "" } else { t }; + Self::apply(setting, &view, value) + } + Setting::LiveSection | Setting::VisualStyle => { + if t == "?" { + self.step = Step::SettingValueList(setting, view); + return CmdResult::NeedPoint; + } + if t == "*" { + return Self::apply(setting, &view, ""); + } + let known = if setting == Setting::LiveSection { + &self.names.live_sections + } else { + &self.names.visual_styles + }; + match known.iter().find(|name| name.eq_ignore_ascii_case(t)) { + Some(name) => { + let name = name.clone(); + Self::apply(setting, &view, &name) + } + None => { + self.step = Step::SettingValue(setting, view); + CmdResult::ReportError( + if setting == Setting::LiveSection { + "Invalid live section name." + } else { + "Invalid visual style name." + } + .into(), + ) + } + } + } + } + } + + fn value_listing(&self, setting: Setting, pattern: &str) -> String { + let pattern = if pattern.trim().is_empty() { "*" } else { pattern }; + match setting { + Setting::Categorize => { + let mut categories: Vec<&str> = self + .names + .views + .iter() + .map(|view| view.category.as_str()) + .filter(|category| !category.is_empty()) + .collect(); + categories.sort_unstable_by_key(|name| name.to_ascii_uppercase()); + categories.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + let found = matching(categories, pattern); + let mut out = String::from("Saved categories:"); + if !found.is_empty() { + out.push_str("\nCategory name"); + for name in found { + out.push('\n'); + out.push_str(name); + } + } + out + } + Setting::VisualStyle | Setting::LiveSection => { + let known = if setting == Setting::VisualStyle { + &self.names.visual_styles + } else { + &self.names.live_sections + }; + let mut found = matching(known.iter().map(String::as_str), pattern); + found.sort_unstable_by_key(|name| name.to_ascii_uppercase()); + if found.is_empty() { + if setting == Setting::VisualStyle { + " No matching visual styles found.".into() + } else { + " No matching live sections found.".into() + } + } else { + found.join("\n") + } + } + _ => String::new(), + } + } +} + +impl CadCommand for DashViewCommand { + fn name(&self) -> &'static str { + "-VIEW" + } + + fn prompt(&self) -> String { + match &self.step { + Step::Main => "Enter an option [?/Delete/Orthographic/Restore/Save/sEttings/Window]:".into(), + Step::Orthographic => "Enter an option [Top/Bottom/Front/BAck/Left/Right]:".into(), + Step::List | Step::SettingViewList(_) => "Enter view name(s) to list <*>:".into(), + Step::Delete => "Enter view name(s) to delete:".into(), + Step::Restore => "Enter view name to restore:".into(), + Step::Save | Step::WindowName => "Enter view name to save:".into(), + Step::WindowFirst(_) => "Specify first corner:".into(), + Step::WindowSecond(..) => "Specify opposite corner:".into(), + Step::Settings => { + "Enter an option [Background/Categorize/Layer snapshot/live Section/Ucs/Visual style]:".into() + } + Step::SettingView(setting) => setting.view_prompt().into(), + Step::SettingValue(setting, view) => self.value_prompt(*setting, view), + Step::SettingValueList(Setting::Categorize, _) => "Enter category name(s) to list <*>:".into(), + Step::SettingValueList(Setting::LiveSection, _) => "Enter live section name(s) to list <*>:".into(), + Step::SettingValueList(_, _) => "Enter visual style name(s) to list <*>:".into(), + Step::Ucs => format!( + "Save current UCS with named views?[Yes/No]<{}>:", + if ucs_view() { "Yes" } else { "No" } + ), + } + } + + fn wants_text_input(&self) -> bool { + true + } + + fn on_text_input(&mut self, text: &str) -> Option { + let t = text.trim(); + // The window corners take typed coordinates as points. + if matches!(self.step, Step::WindowFirst(_) | Step::WindowSecond(..)) + && crate::app::helpers::parse_coord(t).is_some() + { + return None; + } + if t.is_empty() { + return Some(self.on_enter()); + } + let step = std::mem::replace(&mut self.step, Step::Main); + Some(match step { + Step::Main => { + let word = t.trim_start_matches('_').to_ascii_uppercase(); + if let Some(preset) = PRESETS.iter().find(|preset| **preset == word) { + return Some(CmdResult::Dispatch(format!("-VIEW >PRESET {preset}"))); + } + match keyword(t, &["?", "Delete", "Orthographic", "Restore", "Save", "sEttings", "Window"]) { + Some("?") => self.step = Step::List, + Some("Delete") => self.step = Step::Delete, + Some("Orthographic") => self.step = Step::Orthographic, + Some("Restore") => self.step = Step::Restore, + Some("Save") => self.step = Step::Save, + Some("sEttings") => self.step = Step::Settings, + Some(_) => self.step = Step::WindowName, + None => return Some(CmdResult::ReportError("Invalid option keyword.".into())), + } + CmdResult::NeedPoint + } + Step::Orthographic => { + match keyword(t, &["Top", "Bottom", "Front", "BAck", "Left", "Right"]) { + Some(face) => CmdResult::Dispatch(format!("-VIEW >PRESET {}", face.to_ascii_uppercase())), + None => { + self.step = Step::Orthographic; + CmdResult::ReportError("Invalid option keyword.".into()) + } + } + } + Step::List => CmdResult::Measurement(self.list_views(t)), + Step::Delete => { + let names: Vec<&str> = self.names.views.iter().map(|view| view.name.as_str()).collect(); + if matching(names, t).is_empty() { + Self::end("No matching view names found.") + } else { + CmdResult::Dispatch(format!("-VIEW >DELETE {t}")) + } + } + Step::Restore => match self.view(t) { + Some(view) => CmdResult::Dispatch(format!("-VIEW >RESTORE {}", view.name)), + None => Self::end(&format!("Cannot find view \"{t}\".")), + }, + Step::Save => CmdResult::Dispatch(format!("-VIEW >SAVE {t}")), + Step::WindowName => { + self.step = Step::WindowFirst(t.to_string()); + CmdResult::NeedPoint + } + Step::WindowFirst(_) | Step::WindowSecond(..) => Self::end("Invalid window specification."), + Step::Settings => { + match keyword( + t, + &["Background", "Categorize", "Layer snapshot", "live Section", "Ucs", "Visual style"], + ) { + Some("Background") => self.step = Step::SettingView(Setting::Background), + Some("Categorize") => self.step = Step::SettingView(Setting::Categorize), + Some("Layer snapshot") => self.step = Step::SettingView(Setting::LayerSnapshot), + Some("live Section") => self.step = Step::SettingView(Setting::LiveSection), + Some("Ucs") => self.step = Step::Ucs, + Some(_) => self.step = Step::SettingView(Setting::VisualStyle), + None => { + self.step = Step::Settings; + return Some(CmdResult::ReportError("Invalid option keyword.".into())); + } + } + CmdResult::NeedPoint + } + Step::SettingView(setting) => { + if t == "?" { + self.step = Step::SettingViewList(setting); + return Some(CmdResult::NeedPoint); + } + match self.view(t).map(|view| view.name.clone()) { + Some(view) => { + self.step = Step::SettingValue(setting, view); + CmdResult::NeedPoint + } + None => { + self.step = Step::SettingView(setting); + CmdResult::ReportError("No matching view names found.".into()) + } + } + } + Step::SettingViewList(setting) => { + self.step = Step::SettingView(setting); + CmdResult::ReportMeasurement(self.list_views(t)) + } + Step::SettingValue(setting, view) => self.on_setting_value(setting, view, t), + Step::SettingValueList(setting, view) => { + let listing = self.value_listing(setting, t); + self.step = Step::SettingValue(setting, view); + CmdResult::ReportMeasurement(listing) + } + Step::Ucs => match keyword(t, &["Yes", "No"]) { + Some(answer) => CmdResult::Dispatch(format!("-VIEW >UCSVIEW {}", u8::from(answer == "Yes"))), + None => { + self.step = Step::Ucs; + CmdResult::ReportError("Invalid option keyword.".into()) + } + }, + }) + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + match std::mem::replace(&mut self.step, Step::Main) { + Step::WindowFirst(name) => { + self.step = Step::WindowSecond(name, pt); + CmdResult::NeedPoint + } + Step::WindowSecond(name, first) => CmdResult::Dispatch(format!( + "-VIEW >WINDOW {}\u{1f}{},{}\u{1f}{},{}", + name, first.x, first.y, pt.x, pt.y + )), + step => { + self.step = step; + CmdResult::NeedPoint + } + } + } + + fn on_enter(&mut self) -> CmdResult { + match std::mem::replace(&mut self.step, Step::Main) { + Step::Main | Step::Delete | Step::Restore | Step::Save | Step::WindowName => { + CmdResult::Dispatch("-VIEW >END".into()) + } + Step::Orthographic => CmdResult::Dispatch("-VIEW >PRESET TOP".into()), + Step::List => CmdResult::Measurement(self.list_views("*")), + Step::WindowFirst(_) | Step::WindowSecond(..) => Self::end("Invalid window specification."), + // Enter at the settings prompt goes back to the main prompt. + Step::Settings => CmdResult::NeedPoint, + Step::SettingView(_) | Step::Ucs => { + self.step = Step::Settings; + CmdResult::NeedPoint + } + Step::SettingViewList(setting) => { + self.step = Step::SettingView(setting); + CmdResult::ReportMeasurement(self.list_views("*")) + } + // Enter keeps the value shown as the default. + Step::SettingValue(..) => { + self.step = Step::Settings; + CmdResult::NeedPoint + } + Step::SettingValueList(setting, view) => { + let listing = self.value_listing(setting, "*"); + self.step = Step::SettingValue(setting, view); + CmdResult::ReportMeasurement(listing) + } + } + } +}