diff --git a/src/app/block_editor.rs b/src/app/block_editor.rs index a292820d3..f34480746 100644 --- a/src/app/block_editor.rs +++ b/src/app/block_editor.rs @@ -250,6 +250,7 @@ impl OpenCADStudio { return None; } Some(DeltaSnapshot { + visibility_states: None, entities, current_layout_before: session.return_layout.clone(), current_layout_after: session.return_layout.clone(), diff --git a/src/app/block_editor_tests.rs b/src/app/block_editor_tests.rs index 8621214e8..3fab2b3ce 100644 --- a/src/app/block_editor_tests.rs +++ b/src/app/block_editor_tests.rs @@ -59,6 +59,239 @@ fn feed(app: &mut OpenCADStudio, text: &str) { let _ = app.update(Message::CommandSubmit); } +fn visibility_editor() -> (OpenCADStudio, crate::ui::window::block_visibility::Target) { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let _ = app.run_command_line("BPARAMETER VISIBILITY 0,0"); + let target = app + .block_visibility_context() + .expect("placed parameter") + .target; + (app, target) +} + +fn visibility_action(app: &mut OpenCADStudio, action: crate::ui::window::block_visibility::Action) { + let _ = app.update(Message::BlockVisibility(action)); +} + +#[test] +fn visibility_ribbon_manager_cancel_validation_and_single_undo() { + use crate::ui::window::block_visibility::Action as A; + let (mut app, target) = visibility_editor(); + let i = app.active_tab; + let before = app.tabs[i].scene.document.clone(); + let depth = app.tabs[i].history.undo_stack.len(); + visibility_action(&mut app, A::Open(target)); + visibility_action(&mut app, A::Name("Alternate".into())); + visibility_action(&mut app, A::New); + assert_eq!(app.tabs[i].scene.document, before, "dialog is staged"); + let _ = app.update(Message::CommandEscape); + assert!(app.block_visibility.is_none()); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth); + assert_eq!(app.tabs[i].scene.document, before); + visibility_action(&mut app, A::Open(target)); + visibility_action(&mut app, A::Name("visibilitystate0".into())); + visibility_action(&mut app, A::New); + assert!(app.block_visibility.as_ref().unwrap().error.is_some()); + visibility_action(&mut app, A::Delete); + assert_eq!( + app.block_visibility.as_ref().unwrap().edited.states.len(), + 1 + ); + visibility_action(&mut app, A::Name("Alternate".into())); + visibility_action(&mut app, A::New); + visibility_action(&mut app, A::SetCurrent); + visibility_action(&mut app, A::Name("Alternate renamed".into())); + visibility_action(&mut app, A::Rename); + visibility_action(&mut app, A::Move(true)); + visibility_action(&mut app, A::Apply); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth + 1); + assert_eq!( + app.block_visibility_context().unwrap().current.as_deref(), + Some("Alternate renamed") + ); + app.undo_steps(1); + assert_eq!( + app.block_visibility_context().unwrap().names, + ["VisibilityState0"] + ); + app.redo_steps(1); + assert_eq!( + app.block_visibility_context().unwrap().names, + ["Alternate renamed", "VisibilityState0"] + ); + assert_eq!( + app.block_visibility_context().unwrap().current.as_deref(), + Some("Alternate renamed") + ); +} + +#[test] +fn visibility_ribbon_new_modes_state_selection_and_native_roundtrip() { + use crate::ui::window::block_visibility::{Action as A, NewVisibility as V}; + let (mut app, target) = visibility_editor(); + let i = app.active_tab; + let member = members(&app)[0]; + visibility_action(&mut app, A::Open(target)); + for (name, mode) in [("Hidden", V::Hide), ("Shown", V::Show), ("Copy", V::Keep)] { + visibility_action(&mut app, A::Name(name.into())); + visibility_action(&mut app, A::NewVisibility(mode)); + visibility_action(&mut app, A::New); + } + visibility_action(&mut app, A::Apply); + let parameter = + crate::scene::block_visibility::parameter(&app.tabs[i].scene.document, target.definition) + .unwrap(); + assert!(parameter.states[0].visible_blocks.contains(&member)); + assert!(parameter.states[1].visible_blocks.is_empty()); + assert!(parameter.states[2].visible_blocks.contains(&member)); + assert!(parameter.states[3].visible_blocks.contains(&member)); + visibility_action(&mut app, A::Current(target, "Hidden".into())); + assert!( + app.tabs[i] + .scene + .document + .get_entity(member) + .unwrap() + .common() + .invisible + ); + visibility_action(&mut app, A::Current(target, "Shown".into())); + visibility_action(&mut app, A::Current(target, "Copy".into())); + app.undo_steps(1); + assert_eq!( + app.block_visibility_context().unwrap().current.as_deref(), + Some("Shown"), + "identical states retain their identity in history" + ); + app.redo_steps(1); + assert_eq!( + app.block_visibility_context().unwrap().current.as_deref(), + Some("Copy") + ); + app.save_block_edit(i); + for ext in ["dwg", "dxf"] { + let document = &app.tabs[i].scene.document; + let bytes = crate::io::save_to_bytes(document, ext, document.version).unwrap(); + let loaded = crate::io::load_bytes(&format!("visibility.{ext}"), bytes).unwrap(); + let parameter = + crate::scene::block_visibility::parameter(&loaded, target.definition).unwrap(); + assert_eq!( + parameter + .states + .iter() + .map(|state| state.name.as_str()) + .collect::>(), + ["VisibilityState0", "Hidden", "Shown", "Copy"] + ); + assert!(parameter.states[1].visible_blocks.is_empty()); + assert!(parameter.states[2].visible_blocks.contains(&member)); + } +} + +#[test] +fn visibility_ribbon_mode_and_make_visible_cancel_do_not_change_saved_flags() { + use crate::ui::window::block_visibility::Action as A; + let (mut app, target) = visibility_editor(); + let i = app.active_tab; + let member = members(&app)[0]; + app.tabs[i].scene.select_entity(member, true); + let _ = app.run_command_line("BVHIDE"); + app.finish_pending_history(i); + let before = app.tabs[i].scene.document.clone(); + let depth = app.tabs[i].history.undo_stack.len(); + let dirty = app.tabs[i].dirty; + let wires = |app: &OpenCADStudio| { + app.tabs[i] + .scene + .entity_wires() + .iter() + .filter(|wire| crate::scene::Scene::handle_from_wire_name(&wire.name) == Some(member)) + .cloned() + .collect::>() + }; + assert!(wires(&app).is_empty()); + visibility_action(&mut app, A::Mode(target)); + assert!( + !wires(&app).is_empty(), + "hidden member is rendered for picking" + ); + assert_eq!(app.tabs[i].scene.document, before); + visibility_action(&mut app, A::Mode(target)); + assert!(wires(&app).is_empty()); + let _ = app.run_command_line("BVSHOW"); + app.sync_block_visibility_display(); + assert!(!wires(&app).is_empty()); + let _ = app.update(Message::CommandEscape); + assert!(wires(&app).is_empty()); + assert_eq!(app.tabs[i].scene.document, before); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth); + assert_eq!(app.tabs[i].dirty, dirty); + let _ = app.run_command_line("BVMODE 1"); + assert!(app.block_visibility_mode); + let _ = app.run_command_line("SETVAR BVMODE 2"); + assert!(app.block_visibility_mode); + let _ = app.run_command_line("BVMODE 0"); + assert!(!app.block_visibility_mode); + assert!( + app.pending_setvar.is_none(), + "inline variable must close its probe prompt" + ); + let _ = app.run_command_line("BVSHOW"); + app.tabs[i].scene.select_entity(member, true); + let result = app.tabs[i] + .active_cmd + .as_mut() + .unwrap() + .on_selection_complete(vec![member]); + let _ = app.apply_cmd_result(result); + feed(&mut app, ""); + assert!( + !app.tabs[i] + .scene + .document + .get_entity(member) + .unwrap() + .common() + .invisible + ); + app.undo_steps(1); + assert!( + app.tabs[i] + .scene + .document + .get_entity(member) + .unwrap() + .common() + .invisible, + "undo uses the hidden flags from before picking" + ); +} + +#[test] +fn visibility_ribbon_manager_rejects_stale_target_and_stale_document() { + use crate::ui::window::block_visibility::Action as A; + let (mut app, target) = visibility_editor(); + visibility_action(&mut app, A::Open(target)); + visibility_action(&mut app, A::Name("Alternate".into())); + visibility_action(&mut app, A::New); + app.tabs[app.active_tab].scene.bump_geometry(); + visibility_action(&mut app, A::Apply); + assert!(app.block_visibility.as_ref().unwrap().error.is_some()); + assert_eq!( + app.block_visibility_context().unwrap().names, + ["VisibilityState0"] + ); + let _ = app.update(Message::CloseModal); + let mut stale = target; + stale.document += 1; + visibility_action(&mut app, A::Open(stale)); + assert!(app.block_visibility.is_none()); + assert!(app.active_modal.is_none()); + let _ = app.run_command_line("BVSTATE DIALOG"); + assert_eq!(app.active_modal, Some(ModalKind::BlockVisibility)); +} + #[test] fn local_undo_preserves_imported_native_networks_when_constraints_are_unchanged() { use codec::objects::{ diff --git a/src/app/block_visibility.rs b/src/app/block_visibility.rs new file mode 100644 index 000000000..c74fa8efd --- /dev/null +++ b/src/app/block_visibility.rs @@ -0,0 +1,261 @@ +use super::{Message, ModalKind, OpenCADStudio}; +use crate::scene::block_visibility as visibility; +use crate::ui::window::block_visibility::{Action, NewVisibility, RibbonContext, State, Target}; +use iced::Task; + +impl OpenCADStudio { + pub(super) fn block_visibility_context(&self) -> Option { + let tab = self.tabs.get(self.active_tab)?; + let definition = tab.active_block_edit_session()?.br_handle; + let parameter = visibility::parameter(&tab.scene.document, definition)?; + if parameter.states.is_empty() { + return None; + } + let current = self.visibility_current(self.active_tab, definition); + Some(RibbonContext { + target: Target { + document: tab.id, + definition, + parameter: parameter.handle, + }, + names: parameter + .states + .iter() + .map(|state| state.name.clone()) + .collect(), + current: parameter + .states + .get(current) + .map(|state| state.name.clone()), + mode: self.block_visibility_mode, + }) + } + + fn visibility_target_valid(&self, target: Target) -> bool { + self.block_visibility_context() + .is_some_and(|context| context.target == target) + } + + pub(super) fn sync_block_visibility_display(&mut self) { + for tab in &mut self.tabs { + let definition = tab + .active_block_edit_session() + .map(|session| session.br_handle); + // Resolve imported state flags once when an editor opens, not on + // every view refresh. Subsequent state identity follows history. + if let Some(definition) = definition { + if !tab.scene.visibility_states.contains_key(&definition) { + if let Some(parameter) = visibility::parameter(&tab.scene.document, definition) + { + let current = + visibility::shown_state(&tab.scene.document, parameter.handle); + tab.scene.visibility_states.insert(definition, current); + } + } + } + let editing = definition.is_some(); + let picking = tab + .active_cmd + .as_ref() + .is_some_and(|command| command.reveals_hidden_block_objects()); + tab.scene + .set_block_visibility_reveal(editing && (self.block_visibility_mode || picking)); + } + } + + pub(super) fn handle_block_visibility(&mut self, action: Action) -> Task { + let i = self.active_tab; + match action { + Action::Open(target) => { + if !self.visibility_target_valid(target) { + return Task::none(); + } + // A ribbon click can interrupt a running authoring command. + let cancellation = + if self.tabs[i].active_cmd.is_some() || self.pending_setvar.is_some() { + self.on_command_escape() + } else { + Task::none() + }; + self.sync_block_visibility_display(); + let original = + visibility::parameter(&self.tabs[i].scene.document, target.definition) + .unwrap() + .clone(); + let current = self + .visibility_current(i, target.definition) + .min(original.states.len().saturating_sub(1)); + let name = original + .states + .get(current) + .map(|state| state.name.clone()) + .unwrap_or_default(); + self.block_visibility = Some(State { + target, + edited: original.clone(), + original, + current, + selected: current, + name, + new_visibility: NewVisibility::Keep, + error: None, + epoch: self.tabs[i].scene.geometry_epoch, + }); + self.active_modal = Some(ModalKind::BlockVisibility); + return cancellation; + } + Action::Current(target, name) => { + if !self.visibility_target_valid(target) { + return Task::none(); + } + let parameter = + visibility::parameter(&self.tabs[i].scene.document, target.definition).unwrap(); + let Some(index) = parameter.states.iter().position(|state| state.name == name) + else { + return Task::none(); + }; + if self.visibility_current(i, target.definition) != index { + let cancellation = + if self.tabs[i].active_cmd.is_some() || self.pending_setvar.is_some() { + self.on_command_escape() + } else { + Task::none() + }; + self.push_undo_snapshot(i, "Visibility state"); + self.show_visibility_state(i, target.definition, target.parameter, index); + self.finish_pending_history(i); + return cancellation; + } + } + Action::Mode(target) => { + if self.visibility_target_valid(target) { + self.block_visibility_mode = !self.block_visibility_mode; + self.sync_block_visibility_display(); + } + } + Action::Apply => { + let Some(state) = self.block_visibility.as_ref() else { + return Task::none(); + }; + if !self.visibility_target_valid(state.target) + || self.tabs[i].scene.geometry_epoch != state.epoch + || visibility::parameter(&self.tabs[i].scene.document, state.target.definition) + != Some(&state.original) + { + self.block_visibility.as_mut().unwrap().error = Some( + crate::t!("The block changed. Cancel and reopen Visibility States.") + .into_owned(), + ); + return Task::none(); + } + let state = self.block_visibility.take().unwrap(); + if state.edited != state.original + || state.current != self.visibility_current(i, state.target.definition) + { + self.push_undo_snapshot(i, "Visibility states"); + visibility::replace_states( + &mut self.tabs[i].scene.document, + state.target.definition, + state.edited, + ); + self.show_visibility_state( + i, + state.target.definition, + state.target.parameter, + state.current, + ); + self.finish_pending_history(i); + self.refresh_properties(); + } + self.active_modal = None; + } + action => { + let Some(state) = self.block_visibility.as_mut() else { + return Task::none(); + }; + state.error = None; + match action { + Action::Select(index) => state.select(index), + Action::Name(name) => state.name = name, + Action::NewVisibility(mode) => state.new_visibility = mode, + Action::New | Action::Rename => { + let rename = matches!(action, Action::Rename); + if let Some(error) = state.name_error(rename) { + state.error = Some(error); + return Task::none(); + } + let name = state.name.trim().to_string(); + if rename { + state.edited.states[state.selected].name = name; + } else { + let mut new = state.edited.states[state.current].clone(); + new.name = name; + if state.new_visibility != NewVisibility::Keep { + // Newly controlled geometry was visible in all existing states. + let document = &self.tabs[i].scene.document; + let members = document + .block_records + .iter() + .find(|record| record.handle == state.target.definition) + .map(|record| record.entity_handles.clone()) + .unwrap_or_default(); + for handle in members { + if !state.edited.all_blocks.contains(&handle) { + state.edited.all_blocks.push(handle); + if document + .get_entity(handle) + .is_some_and(|entity| !entity.common().invisible) + { + for old in &mut state.edited.states { + old.visible_blocks.push(handle); + } + } + } + } + new.visible_blocks = if state.new_visibility == NewVisibility::Show + { + state.edited.all_blocks.clone() + } else { + Vec::new() + }; + } + state.edited.states.push(new); + state.select(state.edited.states.len() - 1); + } + } + Action::Delete if state.edited.states.len() > 1 => { + let removed = state.selected; + state.edited.states.remove(removed); + if state.current == removed { + state.current = 0; + } else if state.current > removed { + state.current -= 1; + } + state.select(removed.min(state.edited.states.len() - 1)); + } + Action::SetCurrent => state.current = state.selected, + Action::Move(up) => { + let other = if up { + state.selected.checked_sub(1) + } else { + Some(state.selected + 1) + }; + if let Some(other) = + other.filter(|other| *other < state.edited.states.len()) + { + state.edited.states.swap(state.selected, other); + if state.current == state.selected { + state.current = other; + } else if state.current == other { + state.current = state.selected; + } + state.select(other); + } + } + _ => {} + } + } + } + Task::none() + } +} diff --git a/src/app/commands/bvisibility.rs b/src/app/commands/bvisibility.rs index 441ceeabf..b8d56d268 100644 --- a/src/app/commands/bvisibility.rs +++ b/src/app/commands/bvisibility.rs @@ -97,12 +97,16 @@ impl OpenCADStudio { } /// The state the block shows in the editor. - fn visibility_current(&self, i: usize, block: Handle) -> usize { + pub(in crate::app) fn visibility_current(&self, i: usize, block: Handle) -> usize { let scene = &self.tabs[i].scene; scene .visibility_states .get(&block) .copied() + .filter(|index| { + visibility::parameter(&scene.document, block) + .is_some_and(|parameter| *index < parameter.states.len()) + }) .unwrap_or_else(|| { visibility::parameter(&scene.document, block).map_or(0, |parameter| { visibility::shown_state(&scene.document, parameter.handle) @@ -110,7 +114,13 @@ impl OpenCADStudio { }) } - fn show_visibility_state(&mut self, i: usize, block: Handle, parameter: Handle, state: usize) { + pub(in crate::app) fn show_visibility_state( + &mut self, + i: usize, + block: Handle, + parameter: Handle, + state: usize, + ) { visibility::apply_state(&mut self.tabs[i].scene.document, parameter, state); self.tabs[i].scene.visibility_states.insert(block, state); self.tabs[i].scene.bump_geometry(); @@ -143,6 +153,40 @@ impl OpenCADStudio { } } } + let variable = cmd.strip_prefix("SETVAR ").unwrap_or(cmd); + if variable == "BVMODE" || variable.starts_with("BVMODE ") { + let value = variable.strip_prefix("BVMODE").unwrap().trim(); + // Inline command probing may have opened the bare variable prompt. + if !value.is_empty() && self.pending_setvar.as_deref() == Some("BVMODE") { + self.pending_setvar = None; + } + match value { + "" => { + self.command_line.push_output( + crate::tf!( + "BVMODE: enter 0 (hide) or 1 (show dimmed) <{}>:", + u8::from(self.block_visibility_mode) + ) + .as_ref(), + ); + self.pending_setvar = Some("BVMODE".into()); + } + "TOGGLE" => { + self.block_visibility_mode = !self.block_visibility_mode; + self.sync_block_visibility_display(); + } + value @ ("0" | "1") => { + self.block_visibility_mode = value == "1"; + self.sync_block_visibility_display(); + self.command_line + .push_output(crate::tf!("BVMODE = {}", value).as_ref()); + } + _ => self + .command_line + .push_error(crate::t!("BVMODE: only 0 or 1 is allowed.").as_ref()), + } + return Some(Task::none()); + } match cmd { cmd if cmd == "BPARAMETER" || cmd.starts_with("BPARAMETER ") => { let Some(block) = self.visibility_block(i, "BPARAMETER") else { @@ -165,6 +209,17 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(command)); Some(Task::none()) } + "BVSTATE DIALOG" => { + if let Some(context) = self.block_visibility_context() { + return Some(self.handle_block_visibility( + crate::ui::window::block_visibility::Action::Open(context.target), + )); + } + self.command_line.push_error( + crate::t!("Place a Visibility parameter in the Block Editor first.").as_ref(), + ); + Some(Task::none()) + } "BVSTATE" => { let block = self.visibility_block(i, "BVSTATE")?; let document = &self.tabs[i].scene.document; @@ -221,25 +276,8 @@ impl OpenCADStudio { Some(Task::none()) } "BVHIDE" | "BVSHOW" => { - let block = self.visibility_block(i, cmd)?; + self.visibility_block(i, cmd)?; let hide = cmd == "BVHIDE"; - if !hide { - // Every object of the block shows while they are picked; - // the state is put back once the pick ends. - let document = &mut self.tabs[i].scene.document; - let members: Vec = document - .block_records - .iter() - .find(|record| record.handle == block) - .map(|record| record.entity_handles.clone()) - .unwrap_or_default(); - for handle in members { - if let Some(entity) = document.get_entity_mut(handle) { - entity.common_mut().invisible = false; - } - } - self.tabs[i].scene.bump_geometry(); - } let pending = if hide { "_BVHIDE" } else { "_BVSHOW" }; if !self.tabs[i].scene.selected.is_empty() { return Some(self.dispatch_command(pending)); diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs index 3219dc615..eeae91bcf 100644 --- a/src/app/dock/tests.rs +++ b/src/app/dock/tests.rs @@ -692,6 +692,97 @@ fn floating_menu_docks_and_allow_docking_keeps_a_drag_floating() { ); } +#[test] +fn visibility_ribbon_real_controls_disable_enable_and_open_manager() { + with_stack(|| { + use crate::ui::window::block_visibility::Action as A; + let mut app = authoring_app(); + app.ribbon.set_block_editing(true); + app.ribbon + .set_collapse_mode(crate::ui::ribbon::CollapseMode::Compact); + let i = app.active_tab; + let before = app.tabs[i].scene.document.clone(); + let point = authoring_control(&app, "BVSTATE_MANAGER"); + authoring_click(&mut app, point); + assert!(app.block_visibility.is_none()); + assert_eq!(app.tabs[i].scene.document, before); + authoring_capture(&app, "visibility-ribbon-disabled"); + let _ = app.run_command_line("BPARAMETER VISIBILITY 0.5,0.5"); + let target = app.block_visibility_context().unwrap().target; + let point = authoring_control(&app, "BVSTATE_MANAGER"); + authoring_click(&mut app, point); + assert_eq!( + app.active_modal, + Some(crate::app::ModalKind::BlockVisibility) + ); + let _ = app.update(Message::BlockVisibility(A::Name("Alternate".into()))); + let _ = app.update(Message::BlockVisibility(A::New)); + authoring_capture(&app, "visibility-states-dialog"); + let point = find_in_view(&app, "OK"); + authoring_click(&mut app, point); + assert!(app.block_visibility.is_none()); + assert_eq!(app.block_visibility_context().unwrap().names.len(), 2); + let messages = { + let mut simulator = iced_test::Simulator::with_size( + iced::Settings::default(), + VIEW_SIZE, + app.view_main(), + ); + let target = simulator + .click(iced::widget::Id::new("BVSTATE_CURRENT")) + .expect("current-state selector"); + let bounds = target.visible_bounds().unwrap(); + authoring_snapshot( + &mut simulator, + &app.active_theme, + "visibility-state-dropdown", + ); + // Native pick-list options are painted text, not Text widgets. + // Pick the second row using the measured selector/row height. + let position = iced::Point::new(bounds.center_x(), bounds.y + bounds.height * 2.5); + simulator.point_at(position); + simulator.simulate([ + iced::Event::Mouse(iced::mouse::Event::CursorMoved { position }), + iced::Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left)), + iced::Event::Mouse(iced::mouse::Event::ButtonReleased( + iced::mouse::Button::Left, + )), + ]); + simulator.into_messages().collect::>() + }; + for message in messages { + let _ = app.update(message); + } + assert_eq!( + app.block_visibility_context().unwrap().current.as_deref(), + Some("Alternate") + ); + let point = authoring_control(&app, "BVMODE_TOGGLE"); + authoring_click(&mut app, point); + assert!(app.block_visibility_mode); + authoring_capture(&app, "visibility-ribbon-enabled"); + app.ribbon + .set_collapse_mode(crate::ui::ribbon::CollapseMode::Collapsed); + let point = find_in_view(&app, "Visibility ▾"); + authoring_click(&mut app, point); + let messages = { + let mut simulator = iced_test::Simulator::with_size( + iced::Settings::default(), + VIEW_SIZE, + app.view_main(), + ); + simulator + .click("Visibility States") + .expect("manager in flyout"); + simulator.into_messages().collect::>() + }; + for message in messages { + let _ = app.update(message); + } + assert_eq!(app.block_visibility.as_ref().unwrap().target, target); + }); +} + fn authoring_app() -> OpenCADStudio { use codec::{ entities::{Insert, Line}, diff --git a/src/app/document.rs b/src/app/document.rs index 7e5e14500..77871252f 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -797,6 +797,9 @@ impl HistorySnapshot { .saturating_add(d.named_parameters.as_ref().map_or(0, |(before, after)| { before.len().saturating_add(after.len()).saturating_mul(128) })) + .saturating_add(d.visibility_states.as_ref().map_or(0, |(before, after)| { + (before.len() + after.len()).saturating_mul(32) + })) .saturating_add(d.reference_designations.len().saturating_mul(16)) .saturating_add(d.label.len()), HistorySnapshot::ObjectVisibility(v) => v @@ -833,6 +836,11 @@ pub(super) struct ObjectVisibilitySnapshot { /// cloning the flat entity store. #[derive(Clone)] pub(super) struct DeltaSnapshot { + /// Editor current-state indices must follow undo even when two states show identical objects. + pub(super) visibility_states: Option<( + rustc_hash::FxHashMap, + rustc_hash::FxHashMap, + )>, pub(super) entities: Vec<(Handle, Option>, Option>)>, pub(super) current_layout_before: String, pub(super) current_layout_after: String, @@ -949,6 +957,7 @@ pub(super) struct HistoryState { } pub(super) struct PendingHistorySnapshot { + pub(super) visibility_states_before: rustc_hash::FxHashMap, pub(super) label: String, pub(super) current_layout: String, pub(super) active_layer: String, diff --git a/src/app/history.rs b/src/app/history.rs index 40bfc1694..daec60df5 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -216,6 +216,7 @@ impl OpenCADStudio { } let selected: Vec = self.tabs[i].scene.selected.iter().copied().collect(); let delta = DeltaSnapshot { + visibility_states: None, entities, current_layout_before: self.tabs[i].scene.current_layout.clone(), current_layout_after: self.tabs[i].scene.current_layout.clone(), @@ -346,7 +347,11 @@ impl OpenCADStudio { }) }) .collect(); + let visibility_after = self.tabs[i].scene.visibility_states.clone(); + let visibility_states = (pending.visibility_states_before != visibility_after) + .then_some((pending.visibility_states_before, visibility_after)); if !pending.keep_empty + && visibility_states.is_none() && entities.is_empty() && reference_designations.is_empty() && !structure_changed @@ -357,6 +362,7 @@ impl OpenCADStudio { return; } let delta = DeltaSnapshot { + visibility_states, entities, current_layout_before: pending.current_layout, current_layout_after: self.tabs[i].scene.current_layout.clone(), @@ -403,6 +409,7 @@ impl OpenCADStudio { let structure_before = self.tabs[i].scene.document.snapshot_structure(); let recorder = self.tabs[i].scene.document.begin_entity_change_recording(); self.tabs[i].history.pending = Some(PendingHistorySnapshot { + visibility_states_before: self.tabs[i].scene.visibility_states.clone(), label, current_layout, active_layer: self.tabs[i].active_layer.clone(), @@ -514,6 +521,7 @@ impl OpenCADStudio { } let selected_after = self.tabs[i].scene.selected.iter().copied().collect(); let delta = DeltaSnapshot { + visibility_states: None, entities: Vec::new(), current_layout_before: pending.current_layout, current_layout_after: self.tabs[i].scene.current_layout.clone(), @@ -572,6 +580,7 @@ impl OpenCADStudio { return; } let delta = DeltaSnapshot { + visibility_states: None, entities: Vec::new(), current_layout_before: pending.current_layout, current_layout_after: self.tabs[i].scene.current_layout.clone(), @@ -630,6 +639,7 @@ impl OpenCADStudio { return; } let delta = DeltaSnapshot { + visibility_states: None, entities: Vec::new(), current_layout_before: pending.current_layout, current_layout_after: self.tabs[i].scene.current_layout.clone(), @@ -696,6 +706,7 @@ impl OpenCADStudio { return; } let delta = DeltaSnapshot { + visibility_states: None, entities: Vec::new(), current_layout_before: pending.current_layout, current_layout_after: self.tabs[i].scene.current_layout.clone(), @@ -726,6 +737,7 @@ impl OpenCADStudio { self.finish_pending_history(i); let (text_names, dim_names, object_handles) = after.changed_keys(&before); let delta = DeltaSnapshot { + visibility_states: None, entities: Vec::new(), current_layout_before: self.tabs[i].scene.current_layout.clone(), current_layout_after: self.tabs[i].scene.current_layout.clone(), @@ -932,6 +944,7 @@ impl OpenCADStudio { structure = Some(StructureSnapshot::Objects(objects)); } let delta = DeltaSnapshot { + visibility_states: None, entities, current_layout_before: pending.current_layout, current_layout_after: self.tabs[i].scene.current_layout.clone(), @@ -1131,6 +1144,10 @@ impl OpenCADStudio { let value = if undo { &entry.before } else { &entry.after }; *scene.parametric_constraint_set_mut(entry.scope) = value.clone(); } + if let Some((before, after)) = &d.visibility_states { + scene.visibility_states = if undo { before } else { after }.clone(); + scene.bump_geometry(); + } if let Some((before, after)) = &d.named_parameters { scene.named_parameters = if undo { before } else { after }.clone(); } diff --git a/src/app/mod.rs b/src/app/mod.rs index 8ae7b5b7f..3c88207ee 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -12,6 +12,7 @@ mod block_editor; #[cfg(test)] mod block_editor_tests; mod block_save_as; +mod block_visibility; mod block_properties; #[cfg(test)] mod block_properties_tests; @@ -589,6 +590,8 @@ pub(super) struct OpenCADStudio { block_definition_open_editor: Option, block_edit_picker: Option, block_save_as: Option, + block_visibility: Option, + block_visibility_mode: bool, /// PDF dialogs' working copies; `None` while closed. pdf_attach: Option, point_cloud_attach: Option, @@ -2239,6 +2242,7 @@ pub enum ModalKind { BlockEditChanges, BlockEditPicker, BlockSaveAs, + BlockVisibility, SaveDialog, Recovery, /// Fonts referenced by the drawing are missing on this machine. @@ -2884,6 +2888,7 @@ pub enum Message { BlockEditChangesCancel, BlockSaveAsName(String), BlockSaveAsApply, + BlockVisibility(crate::ui::window::block_visibility::Action), BlockEditPickerName(String), BlockEditPickerSelect(String), BlockEditPickerApply, @@ -4658,6 +4663,8 @@ impl OpenCADStudio { block_definition_open_editor: None, block_edit_picker: None, block_save_as: None, + block_visibility: None, + block_visibility_mode: false, pdf_attach: None, point_cloud_attach: None, attdef_dialog: None, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 5acf99cea..787916d40 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1254,7 +1254,7 @@ impl OpenCADStudio { } } - pub(super) fn on_command_escape(&mut self) -> Task { + pub(in crate::app) fn on_command_escape(&mut self) -> Task { self.tabs[self.active_tab].measurement.clear(); // Escape answers a pending client getpoint request first: no point. if self.control.get_point.is_some() { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 4cba81a39..4014c673a 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -236,6 +236,7 @@ impl OpenCADStudio { Some(AttMan) => self.attman = None, Some(BlockEditPicker) => self.block_edit_picker = None, Some(BlockSaveAs) => self.block_save_as = None, + Some(BlockVisibility) => self.block_visibility = None, Some(PdfAttach) => self.pdf_attach = None, Some(UnderlayLayers) => self.underlay_layers = None, Some(PdfImportSettings) => self.pdf_import_settings = None, @@ -390,6 +391,7 @@ impl OpenCADStudio { let before = self.tabs.get(self.active_tab).map(|tab| tab.id); let task = self.update_message(msg); self.restore_dynamic_ucs_after_command(); + self.sync_block_visibility_display(); if self.tabs.get(self.active_tab).map(|tab| tab.id) != before { self.sync_underlay_tab(); } @@ -9658,6 +9660,9 @@ impl OpenCADStudio { } Message::CloseModal => { + if self.active_modal == Some(super::ModalKind::BlockVisibility) { + self.block_visibility = None; + } // The Invalid Area dialog's ✕ undoes the boundary change, as its Cancel does. if self.active_modal == Some(super::ModalKind::CountInvalidArea) { return self.update(Message::Count( @@ -10670,6 +10675,7 @@ impl OpenCADStudio { Task::none() } Message::BlockSaveAsApply => self.apply_block_save_as_dialog(), + Message::BlockVisibility(action) => self.handle_block_visibility(action), Message::BlockEditPickerName(name) => { if let Some(state) = self.block_edit_picker.as_mut() { state.name = name.clone(); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 31c49ee02..0ea5b1e30 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2218,6 +2218,7 @@ bg={bg_ms:.1}ms n={view_count}" self.tabs[self.active_tab].history.undo_stack.len(), self.tabs[self.active_tab].history.redo_stack.len(), self.show_block_palette, + self.block_visibility_context(), )); } // Split the `chrome` bucket so live `view-detail` traces show diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 2837d2777..82ee8490c 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -86,6 +86,7 @@ impl OpenCADStudio { Some(K::Unsaved) => crate::tr!("modal", "unsaved-changes"), Some(K::BlockEditChanges) => crate::t!("Block Editor").into_owned(), Some(K::BlockEditPicker) => crate::t!("Edit Block Definition").into_owned(), + Some(K::BlockVisibility) => crate::t!("Visibility States").into_owned(), Some(K::BlockSaveAs) => crate::t!("Save Block As").into_owned(), Some(K::PointStyle) => crate::tr!("modal", "point-style"), Some(K::AttributeEditor) => crate::tr!("modal", "attribute-editor"), @@ -520,6 +521,12 @@ impl OpenCADStudio { crate::ui::window::block_edit_picker::view(state, flow) }) } + super::super::ModalKind::BlockVisibility => { + let state = self.block_visibility.as_ref()?; + sized_flow(ex, 580, 470, |flow| { + crate::ui::window::block_visibility::view(state, flow) + }) + } super::super::ModalKind::BlockSaveAs => { let state = self.block_save_as.as_ref()?; sized_flow(ex, 430, 370, |flow| { diff --git a/src/command.rs b/src/command.rs index 262b10211..7f08ee1ed 100644 --- a/src/command.rs +++ b/src/command.rs @@ -2991,6 +2991,12 @@ pub trait CadCommand: Send { /// While true, viewport clicks are routed through the normal selection /// system (single / box / polygon) instead of the command's point-pick path. /// After each completed selection action the host calls `on_selection_complete`. + /// A selection command may reveal state-hidden BEDIT members for picking. + /// This is a display request only; the document visibility flags stay intact. + fn reveals_hidden_block_objects(&self) -> bool { + false + } + fn is_selection_gathering(&self) -> bool { false } diff --git a/src/modules/block_editor/mod.rs b/src/modules/block_editor/mod.rs index 4a61a26da..c45fcf76f 100644 --- a/src/modules/block_editor/mod.rs +++ b/src/modules/block_editor/mod.rs @@ -94,6 +94,41 @@ impl CadModule for BlockEditorModule { ), ); } + groups.push(RibbonGroup { + title: "Visibility", + tools: vec![ + RibbonItem::LargeTool(ToolDef { + id: "BVSTATE_MANAGER", + label: "Visibility States", + icon: IconKind::Svg(include_bytes!("../../../assets/icons/ui/list.svg")), + event: ModuleEvent::Command("BVSTATE DIALOG".into()), + }), + RibbonItem::Tool(ToolDef { + id: "BVMODE_TOGGLE", + label: "Visibility Mode", + icon: IconKind::Svg(include_bytes!( + "../../../assets/icons/constrain/show_all.svg" + )), + event: ModuleEvent::Command("BVMODE TOGGLE".into()), + }), + RibbonItem::Tool(ToolDef { + id: "BVSHOW", + label: "Make Visible", + icon: IconKind::Svg(include_bytes!( + "../../../assets/icons/constrain/show.svg" + )), + event: ModuleEvent::Command("BVSHOW".into()), + }), + RibbonItem::Tool(ToolDef { + id: "BVHIDE", + label: "Make Invisible", + icon: IconKind::Svg(include_bytes!( + "../../../assets/icons/constrain/hide_all.svg" + )), + event: ModuleEvent::Command("BVHIDE".into()), + }), + ], + }); groups }) } diff --git a/src/modules/draw/modify/bvstate.rs b/src/modules/draw/modify/bvstate.rs index 3dfe7cb93..04cdc08a4 100644 --- a/src/modules/draw/modify/bvstate.rs +++ b/src/modules/draw/modify/bvstate.rs @@ -66,7 +66,8 @@ impl CadCommand for BvStateCommand { fn prompt(&self) -> String { match &self.step { Step::Option => { - crate::t!("BVSTATE Enter an option [New/Set/Rename/Delete] :").into_owned() + crate::t!("BVSTATE Enter an option [New/Set/Rename/Delete/Dialog] :") + .into_owned() } Step::NewName => crate::t!("BVSTATE Enter name of new visibility state:").into_owned(), Step::SetName => crate::tf!( @@ -93,6 +94,7 @@ impl CadCommand for BvStateCommand { CmdOption::new(crate::t!("Set").as_ref(), "S"), CmdOption::new(crate::t!("Rename").as_ref(), "R"), CmdOption::new(crate::t!("Delete").as_ref(), "D"), + CmdOption::new(crate::t!("Dialog").as_ref(), "DIALOG"), ], _ => Vec::new(), } @@ -114,6 +116,7 @@ impl CadCommand for BvStateCommand { match &self.step { Step::Option => { self.step = match text.trim_start_matches('_').to_ascii_uppercase().as_str() { + "DIALOG" => return Some(CmdResult::Dispatch("BVSTATE DIALOG".into())), "N" | "NEW" => Step::NewName, "" | "S" | "SET" => Step::SetName, "R" | "RENAME" => Step::RenameFrom, @@ -196,5 +199,5 @@ impl CadCommand for BvStateCommand { } inventory::submit!(crate::command::CommandRegistration { - names: &["BVSTATE", "BVHIDE", "BVSHOW"] + names: &["BVSTATE", "BVHIDE", "BVSHOW", "BVMODE"] }); diff --git a/src/modules/draw/select.rs b/src/modules/draw/select.rs index 392ecf54f..463e9e1a1 100644 --- a/src/modules/draw/select.rs +++ b/src/modules/draw/select.rs @@ -177,6 +177,10 @@ impl SelectObjectsCommand { } impl CadCommand for SelectObjectsCommand { + fn reveals_hidden_block_objects(&self) -> bool { + self.pending_cmd == "_BVSHOW" + } + fn name(&self) -> &'static str { "SELECT" } diff --git a/src/scene/block_visibility.rs b/src/scene/block_visibility.rs index e618c5ab7..321237760 100644 --- a/src/scene/block_visibility.rs +++ b/src/scene/block_visibility.rs @@ -32,6 +32,46 @@ const MINOR: i32 = 329; /// Value code of an expression that holds no value. const NO_VALUE: i16 = -9999; +impl Scene { + /// BVMODE and BVSHOW picking reveal only state-controlled members of BEDIT. + pub(crate) fn visibility_revealed(&self, handle: Handle) -> bool { + self.block_visibility_reveal + && self.block_edit_block.is_some_and(|definition| { + self.document.get_entity(handle).is_some_and(|entity| { + entity.common().owner_handle == definition && entity.common().invisible + }) && parameter(&self.document, definition) + .is_some_and(|parameter| parameter.all_blocks.contains(&handle)) + }) + } + + pub(crate) fn visibility_reveal_handles(&self) -> rustc_hash::FxHashSet { + self.block_edit_block + .and_then(|definition| parameter(&self.document, definition)) + .filter(|_| self.block_visibility_reveal) + .map(|parameter| { + parameter + .all_blocks + .iter() + .copied() + .filter(|handle| self.visibility_revealed(*handle)) + .collect() + }) + .unwrap_or_default() + } + + pub(crate) fn display_entity_invisible(&self, common: &codec::entities::EntityCommon) -> bool { + common.invisible && !self.visibility_revealed(common.handle) + } + + pub(crate) fn set_block_visibility_reveal(&mut self, reveal: bool) { + if self.block_visibility_reveal != reveal { + self.block_visibility_reveal = reveal; + self.recolor_meshes(); + self.bump_geometry(); + } + } +} + /// (DXF name, class, DWG maintenance version) of the classes used here. const CLASSES: &[(&str, &str, i32)] = &[ ("ACAD_EVALUATION_GRAPH", "AcDbEvalGraph", 55), @@ -131,6 +171,20 @@ fn store(document: &mut CadDocument, parameter: BlockVisibilityParameter) { ); } +/// Commit a staged state list while retaining the parameter's graph and identity. +pub(crate) fn replace_states( + document: &mut CadDocument, + definition: Handle, + edited: BlockVisibilityParameter, +) { + debug_assert_eq!( + parameter(document, definition).map(|p| p.handle), + Some(edited.handle) + ); + store(document, edited); + tag_members(document, definition); +} + /// The objects of a definition, block begin and end left out. fn members(document: &CadDocument, definition: Handle) -> Vec { document @@ -784,6 +838,79 @@ mod tests { use super::*; use codec::entities::{Circle, Insert, Line}; + #[test] + fn visibility_ribbon_reveal_respects_layers_uncontrolled_flags_and_nested_states() { + let mut scene = Scene::new(); + scene.define_block_raw( + "nested", + Vector3::ZERO, + vec![EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::UNIT_X, + ))], + ); + let nested = scene.document.block_records.get("nested").unwrap().handle; + let child = members(&scene.document, nested)[0]; + scene + .document + .get_entity_mut(child) + .unwrap() + .common_mut() + .invisible = true; + scene.define_block_raw( + "editor", + Vector3::ZERO, + vec![ + EntityType::Line(Line::from_points(Vector3::ZERO, Vector3::UNIT_X)), + EntityType::Line(Line::from_points( + Vector3::UNIT_Y, + Vector3::UNIT_X + Vector3::UNIT_Y, + )), + EntityType::Insert(Insert::new("nested", Vector3::ZERO)), + ], + ); + let definition = scene.document.block_records.get("editor").unwrap().handle; + let controlled = members(&scene.document, definition)[0]; + let independent = members(&scene.document, definition)[1]; + scene + .document + .get_entity_mut(independent) + .unwrap() + .common_mut() + .invisible = true; + let parameter = create(&mut scene.document, definition, Vector3::ZERO).unwrap(); + set_visible(&mut scene.document, parameter, 0, &[controlled], false); + apply_state(&mut scene.document, parameter, 0); + scene.block_edit_block = Some(definition); + let before = scene.document.clone(); + scene.set_block_visibility_reveal(true); + let wires = scene.entity_wires(); + assert!(wires + .iter() + .any(|wire| Scene::handle_from_wire_name(&wire.name) == Some(controlled))); + assert!(!scene.visibility_revealed(independent)); + assert!(!scene.visibility_revealed(child)); + assert_eq!(scene.visibility_reveal_handles().len(), 1); + assert_eq!( + scene.select_all_visible(), + 2, + "only revealed line and the insert can be selected" + ); + let color = scene.count_color(controlled, [1.0; 4], [0.0, 0.0, 0.0, 1.0]); + assert_eq!(color, [0.5, 0.5, 0.5, 1.0]); + scene.document.layers.get_mut("0").unwrap().flags.off = true; + scene.bump_geometry(); + assert_eq!( + scene.select_all_visible(), + 0, + "mode never overrides a hidden layer" + ); + scene.document.layers.get_mut("0").unwrap().flags.off = false; + scene.set_block_visibility_reveal(false); + assert_eq!(scene.document, before); + assert!(!scene.visibility_revealed(controlled)); + } + /// A block given visibility states round-trips them, and a reference /// given its own copy still finds the definition and its states. #[test] diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 5fa03261a..2b6c14419 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1510,7 +1510,7 @@ impl Scene { return false; } let c = entity.common(); - if c.invisible + if self.display_entity_invisible(c) || self.entity_temporarily_hidden(handle) || layer_hidden(&c.layer) || crate::scene::annotative::annotative_offscale_for( @@ -1811,6 +1811,11 @@ impl Scene { plot_only: bool, ) -> Vec { let depth_map = self.draw_depth_map(); + let visibility_reveal = if plot_only { + rustc_hash::FxHashSet::default() + } else { + self.visibility_reveal_handles() + }; let graph = crate::scene::render_graph::RenderSceneGraph::new( &self.document, frozen, @@ -1818,6 +1823,7 @@ impl Scene { all_visible, depth_map.as_ref(), ) + .with_visibility_reveal(&visibility_reveal) .with_annotation_scale(self.annotation_scale) .with_viewport(viewport); let mut hatch_block_memo = std::collections::HashMap::new(); @@ -2022,6 +2028,11 @@ impl Scene { plot_only: bool, ) -> Vec { let depth_map = self.draw_depth_map(); + let visibility_reveal = if plot_only { + rustc_hash::FxHashSet::default() + } else { + self.visibility_reveal_handles() + }; let graph = crate::scene::render_graph::RenderSceneGraph::new( &self.document, frozen, @@ -2029,6 +2040,7 @@ impl Scene { all_visible, depth_map.as_ref(), ) + .with_visibility_reveal(&visibility_reveal) .with_annotation_scale(self.annotation_scale); let mut models = Vec::new(); let mut wipeout_sources = rustc_hash::FxHashMap::default(); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index e5001d444..1d2e3c1d6 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2149,6 +2149,7 @@ pub struct Scene { /// The visibility state each dynamic block shows in the Block Editor, /// by block record. pub visibility_states: HashMap, + pub(crate) block_visibility_reveal: bool, /// Currently selected entity handles. pub selected: HashSet, /// Polylines only part of which is selected (Ctrl+click): the picked @@ -2871,6 +2872,7 @@ impl Scene { paper_projected_cache: RefCell::new(HashMap::default()), current_layout: "Model".to_string(), block_edit_block: None, + block_visibility_reveal: false, viewcube_ucs: glam::Mat4::IDENTITY, hatches: HashMap::default(), meshes: HashMap::default(), @@ -4558,6 +4560,11 @@ impl Scene { /// top-level entity): the count colours, else faded; unchanged outside /// count mode. pub(crate) fn count_color(&self, handle: Handle, color: [f32; 4], bg: [f32; 4]) -> [f32; 4] { + let color = if self.visibility_revealed(handle) { + crate::scene::cache::block_cache::fade_toward_bg(color, bg) + } else { + color + }; let Some(count) = &self.count_display else { return color; }; @@ -4578,7 +4585,9 @@ impl Scene { fn apply_refedit_fade(&self, wires: &mut Vec, bg: [f32; 4]) { // Block copies kept as stubs draw in their prototype's colour; while // copies are recoloured one by one they are expanded instead. - if (self.count_display.is_some() || self.refedit_keep.is_some()) + if (self.count_display.is_some() + || self.refedit_keep.is_some() + || self.block_visibility_reveal) && wires.iter().any(|wire| wire.stub.is_some()) { *wires = crate::scene::model::instance_model::expand_stubs(wires).into_owned(); @@ -4603,6 +4612,19 @@ impl Scene { } } } + if self.block_visibility_reveal { + for wire in wires.iter_mut() { + if Self::handle_from_wire_name(&wire.name) + .is_some_and(|handle| self.visibility_revealed(handle)) + { + wire.color = crate::scene::cache::block_cache::fade_toward_bg(wire.color, bg); + for vertex in &mut wire.text_verts { + vertex.color = + crate::scene::cache::block_cache::fade_toward_bg(vertex.color, bg); + } + } + } + } let Some(keep) = &self.refedit_keep else { return; }; @@ -6665,6 +6687,7 @@ impl Scene { /// Persisted `EntityCommon::invisible` values stay untouched and continue /// to serve file/dynamic-block visibility. pub fn reset_transient_visibility(&mut self) { + self.block_visibility_reveal = false; self.object_isolation = ObjectIsolationState::default(); self.preview_hidden.clear(); self.command_preview_hidden.clear(); @@ -7398,6 +7421,7 @@ impl Scene { fn resident_runs_match_memo(&self, block: Handle) -> bool { self.count_display.is_none() && self.refedit_keep.is_none() + && !self.block_visibility_reveal && self.active_live_section(block).is_none() } @@ -8597,6 +8621,11 @@ impl Scene { return Vec::new(); } let depth_map = self.draw_depth_map(); + let visibility_reveal = if plotting { + HashSet::default() + } else { + self.visibility_reveal_handles() + }; let graph = render_graph::RenderSceneGraph::new( &self.document, frozen, @@ -8604,6 +8633,7 @@ impl Scene { all_visible, depth_map.as_ref(), ) + .with_visibility_reveal(&visibility_reveal) .with_annotation_scale(self.annotation_scale) .with_viewport(viewport); let mut models = Vec::new(); @@ -8686,6 +8716,15 @@ impl Scene { translation: [matrix[0][3], matrix[1][3], matrix[2][3]], }); } + if !plotting + && self.visibility_revealed(if context.is_instanced() { + context.root_handle + } else { + handle + }) + { + placed.opacity *= 0.3; + } placed.draw_depth = context.draw_depth(handle, depth_map.as_ref()); models.push(collect(placed, context)); }, @@ -8968,6 +9007,20 @@ impl Scene { material.diffuse[3] = style.0[3]; } } + if self.visibility_revealed(handle) { + for mesh in &mut set.lods { + mesh.color = crate::scene::cache::block_cache::fade_toward_bg( + mesh.color, + self.current_bg(), + ); + } + if let Some(material) = set.material.as_mut() { + material.diffuse = crate::scene::cache::block_cache::fade_toward_bg( + material.diffuse, + self.current_bg(), + ); + } + } Some(set) }) .collect(); @@ -9390,13 +9443,15 @@ impl Scene { } let arc = if point_cloud::any_definition(&self.document) { let depths = rustc_hash::FxHashMap::default(); + let visibility_reveal = self.visibility_reveal_handles(); let graph = render_graph::RenderSceneGraph::new( &self.document, (!frozen.is_empty()).then_some(frozen), None, true, &depths, - ); + ) + .with_visibility_reveal(&visibility_reveal); let mut clouds = Vec::new(); graph.walk_root( self.render_scene_root(target_block), @@ -9473,7 +9528,7 @@ impl Scene { continue; }; let common = entity.common(); - if common.invisible + if self.display_entity_invisible(common) || self.entity_temporarily_hidden(common.handle) || self .document @@ -9639,7 +9694,7 @@ impl Scene { let Some(c) = self.document.get_entity(handle).map(|e| e.common()) else { return false; }; - if c.invisible { + if self.display_entity_invisible(c) { return false; } !self.layer_hidden(&c.layer) @@ -9673,6 +9728,7 @@ impl Scene { return Vec::new(); } let depth_map = self.draw_depth_map(); + let visibility_reveal = self.visibility_reveal_handles(); let graph = render_graph::RenderSceneGraph::new( &self.document, frozen, @@ -9680,6 +9736,7 @@ impl Scene { all_visible, depth_map.as_ref(), ) + .with_visibility_reveal(&visibility_reveal) .with_annotation_scale(self.annotation_scale) .with_viewport(viewport); let mut out = Vec::new(); @@ -9744,7 +9801,7 @@ impl Scene { self.material_base_dir.as_deref(), ); } - if self.count_display.is_some() { + if self.count_display.is_some() || self.visibility_revealed(context.root_handle) { let own = transformed .instance_color .or(set.display_color()) @@ -9973,7 +10030,7 @@ impl Scene { return false; } let common = entity.common(); - if common.invisible + if self.display_entity_invisible(common) || self.entity_temporarily_hidden(handle) || self.layer_hidden(&common.layer) || self.interaction_layer_frozen(&common.layer) @@ -10037,6 +10094,7 @@ impl Scene { .flatten() .copied() .collect(); + let visibility_reveal = self.visibility_reveal_handles(); let graph = render_graph::RenderSceneGraph::new( &self.document, (!frozen.is_empty()).then_some(&frozen), @@ -10044,6 +10102,7 @@ impl Scene { all_visible, depth_map.as_ref(), ) + .with_visibility_reveal(&visibility_reveal) .with_annotation_scale(self.annotation_scale); for entity in self.document.entities() { let contextual = crate::scene::annotative::entity_for_annotation_context( @@ -10053,7 +10112,7 @@ impl Scene { ); let host = contextual.as_ref(); let common = host.common(); - if common.invisible + if self.display_entity_invisible(common) || self.entity_temporarily_hidden(common.handle) || layer_hidden(&common.layer) || crate::scene::annotative::annotative_offscale_for( @@ -11638,7 +11697,7 @@ impl Scene { layer: Option<&codec::tables::layer::Layer>, ) -> bool { let c = e.common(); - if c.invisible { + if self.display_entity_invisible(c) { return false; } // Session-only Isolate / Hide and interactive replacement previews. diff --git a/src/scene/render_graph.rs b/src/scene/render_graph.rs index 1efd898f8..ec4884098 100644 --- a/src/scene/render_graph.rs +++ b/src/scene/render_graph.rs @@ -280,6 +280,7 @@ pub struct RenderSceneGraph<'a> { all_visible: bool, depths: &'a FxHashMap, viewport: Option, + visibility_reveal: Option<&'a FxHashSet>, } impl<'a> RenderSceneGraph<'a> { @@ -298,9 +299,15 @@ impl<'a> RenderSceneGraph<'a> { all_visible, depths, viewport: None, + visibility_reveal: None, } } + pub fn with_visibility_reveal(mut self, handles: &'a FxHashSet) -> Self { + self.visibility_reveal = Some(handles); + self + } + pub fn with_viewport(mut self, viewport: Option) -> Self { self.viewport = viewport.filter(|handle| handle.is_valid()); self @@ -579,7 +586,11 @@ impl<'a> RenderSceneGraph<'a> { fn document_visible(&self, entity: &EntityType) -> bool { let common = entity.common(); - if common.invisible { + if common.invisible + && !self + .visibility_reveal + .is_some_and(|handles| handles.contains(&common.handle)) + { return false; } let layer = self.document.layers.get(&common.layer); diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 5e5cbe032..2f61c9933 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -26,6 +26,7 @@ pub(crate) use draw_panel::tools as panel_tools; mod color_dropdown; mod context_tools; mod modify_panel; +mod visibility_panel; pub use context_tools::{pdf_underlay_tools, point_cloud_tools, xref_tools, UnderlayContext}; use widgets::{StyleContext, *}; pub(crate) use widgets::{REDO_HISTORY_ID, UNDO_HISTORY_ID}; @@ -630,6 +631,7 @@ impl Ribbon { undo_count: usize, redo_count: usize, show_block_palette: bool, + visibility: Option, ) -> Element<'_, Message> { // ── Quick-access file commands + undo/redo, one merged flow ──────── let lead = iced::widget::Row::with_children(vec![ @@ -825,7 +827,15 @@ impl Ribbon { // button. See `CollapsePanels`. let ts = self.toggle_state(show_block_palette); let panels: Vec> = groups - .map(|g| self.panel(g, ts, &style_ctx, &|_| Vec::new())) + .map(|g| { + if self.modules[effective_active].id() == "block_editor" + && g.title == "Visibility" + { + visibility_panel::panel(visibility.clone()) + } else { + self.panel(g, ts, &style_ctx, &|_| Vec::new()) + } + }) .collect(); CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H) .report_height(self.tool_bar_h.clone()) diff --git a/src/ui/ribbon/visibility_panel.rs b/src/ui/ribbon/visibility_panel.rs new file mode 100644 index 000000000..9353e2406 --- /dev/null +++ b/src/ui/ribbon/visibility_panel.rs @@ -0,0 +1,165 @@ +//! The contextual visibility controls share the ribbon's buttons, tips and collapse layout. +use iced::widget::{button, column, container, pick_list, row, text, tooltip}; +use iced::{Element, Fill, Theme}; + +use super::collapse::Panel; +use super::widgets::{make_icon, tool_btn_style, LARGE_ICON, LARGE_W, SMALL_ICON, TOOL_BAR_H}; +use crate::app::Message; +use crate::modules::IconKind; +use crate::ui::window::block_visibility::{Action, RibbonContext}; +use crate::ui::ROW_H; + +fn tool( + id: &'static str, + label: &'static str, + icon: &'static [u8], + large: bool, + active: bool, + message: Option, +) -> Element<'static, Message> { + let enabled = message.is_some(); + let size = if large { LARGE_ICON } else { SMALL_ICON }; + let image = if enabled { + crate::ui::icons::semantic(icon, size) + } else { + crate::ui::icons::semantic_disabled(icon, size) + }; + let face: Element<'static, Message> = if large { + column![ + image, + text(crate::t!(label).into_owned()) + .size(10) + .align_x(iced::Center) + ] + .align_x(iced::Center) + .spacing(3) + .into() + } else { + image + }; + let mut control = button(face) + .on_press_maybe(message) + .style(move |theme: &Theme, status| { + let mut style = tool_btn_style(theme, active, status); + if !enabled { + style.text_color = style.text_color.scale_alpha(0.42); + style.background = None; + } + style + }) + .padding([3, 5]); + control = if large { + control.width(LARGE_W + 8.0).height(3.0 * ROW_H) + } else { + control.width(ROW_H + 4.0).height(ROW_H) + }; + tooltip( + super::keytips::anchor(id, container(control).id(id)), + super::tooltip_content(crate::t!(label).into_owned()), + iced::widget::tooltip::Position::Right, + ) + .gap(6) + .delay(std::time::Duration::from_millis(400)) + .style(super::tooltip_style) + .into() +} + +fn view(context: Option, compact: bool) -> Element<'static, Message> { + let target = context.as_ref().map(|context| context.target); + let mode = context.as_ref().is_some_and(|context| context.mode); + let manager = tool( + "BVSTATE_MANAGER", + "Visibility States", + include_bytes!("../../../assets/icons/ui/list.svg"), + !compact, + false, + target.map(|target| Message::BlockVisibility(Action::Open(target))), + ); + let toggles = row![ + tool( + "BVMODE_TOGGLE", + "Visibility Mode", + include_bytes!("../../../assets/icons/constrain/show_all.svg"), + false, + mode, + target.map(|target| Message::BlockVisibility(Action::Mode(target))) + ), + tool( + "BVSHOW", + "Make Visible", + include_bytes!("../../../assets/icons/constrain/show.svg"), + false, + false, + target.map(|_| Message::Command("BVSHOW".into())) + ), + tool( + "BVHIDE", + "Make Invisible", + include_bytes!("../../../assets/icons/constrain/hide_all.svg"), + false, + false, + target.map(|_| Message::Command("BVHIDE".into())) + ), + ] + .spacing(2); + let names = context + .as_ref() + .map(|context| context.names.clone()) + .unwrap_or_default(); + let current = context.as_ref().and_then(|context| context.current.clone()); + let mut selector = pick_list(current, names, |name: &String| name.clone()) + .placeholder(crate::t!("Visibility state").into_owned()) + .text_size(11) + .padding([3, 6]) + .width(Fill); + if let Some(target) = target { + selector = + selector.on_select(move |name| Message::BlockVisibility(Action::Current(target, name))); + } + let selector = container(selector).id("BVSTATE_CURRENT").width(Fill); + let controls: Element<'static, Message> = if compact { + column![row![manager, toggles].spacing(3), selector] + .spacing(4) + .width(160) + .height(Fill) + .into() + } else { + row![manager, column![toggles, selector].spacing(6).width(170)] + .spacing(4) + .height(Fill) + .into() + }; + column![controls, text(crate::t!("Visibility").into_owned()).size(9)] + .align_x(iced::Center) + .padding([3, 4]) + .height(TOOL_BAR_H) + .into() +} + +fn collapsed(tight: bool) -> Element<'static, Message> { + let icon = make_icon( + IconKind::Svg(include_bytes!("../../../assets/icons/ui/list.svg")), + if tight { SMALL_ICON } else { LARGE_ICON }, + ); + button( + column![icon, text(crate::t!("Visibility ▾").into_owned()).size(10)] + .align_x(iced::Center) + .spacing(4), + ) + .on_press(Message::ToggleRibbonPanel("Visibility".into())) + .style(|theme, status| tool_btn_style(theme, false, status)) + .padding([4, 6]) + .into() +} + +pub(super) fn panel(context: Option) -> Panel<'static> { + Panel { + id: "Visibility".into(), + elements: [ + view(context.clone(), false), + view(context, true), + collapsed(false), + collapsed(true), + ], + } +} diff --git a/src/ui/window/block_visibility.rs b/src/ui/window/block_visibility.rs new file mode 100644 index 000000000..c7bb4b907 --- /dev/null +++ b/src/ui/window/block_visibility.rs @@ -0,0 +1,203 @@ +use codec::objects::BlockVisibilityParameter; +use codec::Handle; +use iced::widget::{ + button, column, container, pick_list, row, scrollable, text, text_input, Space, +}; +use iced::{Element, Fill}; + +use crate::app::Message; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Target { + pub document: u64, + pub definition: Handle, + pub parameter: Handle, +} + +#[derive(Debug, Clone)] +pub struct RibbonContext { + pub target: Target, + pub names: Vec, + pub current: Option, + pub mode: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NewVisibility { + Keep, + Show, + Hide, +} + +impl std::fmt::Display for NewVisibility { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str( + crate::t!(match self { + Self::Keep => "Keep current visibility", + Self::Show => "Show all objects", + Self::Hide => "Hide all objects", + }) + .as_ref(), + ) + } +} + +#[derive(Debug, Clone)] +pub enum Action { + Open(Target), + Current(Target, String), + Mode(Target), + Select(usize), + Name(String), + NewVisibility(NewVisibility), + New, + Rename, + Delete, + SetCurrent, + Move(bool), + Apply, +} + +pub(crate) struct State { + pub target: Target, + pub original: BlockVisibilityParameter, + pub edited: BlockVisibilityParameter, + pub current: usize, + pub selected: usize, + pub name: String, + pub new_visibility: NewVisibility, + pub error: Option, + /// A dialog cannot overwrite changes made by another entry point. + pub epoch: u64, +} + +impl State { + pub fn select(&mut self, index: usize) { + if let Some(state) = self.edited.states.get(index) { + self.selected = index; + self.name = state.name.clone(); + self.error = None; + } + } + + pub fn name_error(&self, renaming: bool) -> Option { + if self.name.trim().is_empty() || self.name.chars().any(char::is_control) { + return Some( + crate::t!("Enter a nonempty state name without control characters.").into_owned(), + ); + } + self.edited + .states + .iter() + .enumerate() + .any(|(index, state)| { + (!renaming || index != self.selected) + && state.name.eq_ignore_ascii_case(self.name.trim()) + }) + .then(|| crate::t!("A visibility state with that name already exists.").into_owned()) + } +} + +pub(crate) fn view(state: &State, sizing: crate::ui::modal::ModalSizing) -> Element<'_, Message> { + let send = |action| Message::BlockVisibility(action); + let mut states = column![].spacing(2); + for (index, item) in state.edited.states.iter().enumerate() { + let label = if index == state.current { + crate::tf!("{} (current)", item.name).into_owned() + } else { + item.name.clone() + }; + states = states.push( + button(text(label).size(13)) + .width(Fill) + .on_press(send(Action::Select(index))) + .style(if index == state.selected { + button::primary + } else { + button::text + }), + ); + } + let control = |label: &'static str, action, enabled: bool| { + button(text(crate::t!(label).into_owned()).size(13)) + .width(Fill) + .on_press_maybe(enabled.then_some(send(action))) + }; + container( + column![ + row![ + container(scrollable(states)) + .width(Fill) + .height(Fill) + .style(container::bordered_box), + column![ + control( + "Set Current", + Action::SetCurrent, + state.selected != state.current + ), + control("Delete", Action::Delete, state.edited.states.len() > 1), + control("Move Up", Action::Move(true), state.selected > 0), + control( + "Move Down", + Action::Move(false), + state.selected + 1 < state.edited.states.len() + ), + ] + .spacing(8) + .width(130), + ] + .spacing(12) + .height(Fill), + text(crate::t!("State name").into_owned()).size(13), + row![ + text_input(crate::t!("State name").as_ref(), &state.name) + .on_input(move |name| send(Action::Name(name))) + .style(crate::ui::style::form::field_style), + button(text(crate::t!("Rename").into_owned())).on_press_maybe( + state + .name_error(true) + .is_none() + .then_some(send(Action::Rename)) + ), + ] + .spacing(8), + row![ + pick_list( + Some(state.new_visibility), + [ + NewVisibility::Keep, + NewVisibility::Show, + NewVisibility::Hide + ], + |mode: &NewVisibility| mode.to_string() + ) + .on_select(move |mode| send(Action::NewVisibility(mode))) + .width(Fill), + button(text(crate::t!("New").into_owned())).on_press_maybe( + state + .name_error(false) + .is_none() + .then_some(send(Action::New)) + ), + ] + .spacing(8), + text(state.error.clone().unwrap_or_default()) + .size(12) + .style(text::danger), + row![ + Space::new().width(Fill), + button(text(crate::t!("OK").into_owned())) + .on_press(send(Action::Apply)) + .style(button::primary), + button(text(crate::t!("Cancel").into_owned())).on_press(Message::CloseModal), + ] + .spacing(8), + ] + .spacing(10), + ) + .padding(16) + .width(sizing.width) + .height(sizing.height) + .into() +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index b2c353ea0..cad035db0 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -10,6 +10,7 @@ pub mod block_definition; pub mod block_edit_picker; pub mod block_palette; pub mod block_save_as; +pub mod block_visibility; pub mod browser; pub mod count_palette; pub mod drafting_settings;